/** * @file OpenAPI 3.0 path + schema fragments for the dashboard's Run feature * (`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 * `Run`. The shared error envelope `ErrorResponse` (`{ error: { code, message } }`) * is defined in the base spec and only referenced here — never redefined. * * @author Nguyễn Ngọc Trí Vĩ */ const tags = [ { name: "Run", 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.", }, ]; /** * 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 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" }, example: { error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" }, }, }, }, }; const errorJson = (description, example) => ({ description, content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, ...(example ? { example } : {}), }, }, }); const schemas = { RunHandle: { type: "object", 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", "laneId", "slot", "status", "tool"], properties: { id: { type: "string", 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", 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: ["running", "gone"], description: "`running` while the tmux session exists. GET /api/run/{id} answers 404 rather than return a `gone` handle.", example: "running", }, 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, example: "/home/dev/projects/my-app" }, model: { type: "string", nullable: true, 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: "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", nullable: true, description: "Spawn time as stored in `dashboard_runs.started_at` (ISO-8601).", }, promptPreview: { type: "string", nullable: true, description: "First characters of the initial prompt, or null.", }, }, }, RunListResponse: { type: "object", 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" } }, }, }, RunHistoryItem: { type: "object", 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", "cwd", "status", "started_at", "isLive"], properties: { id: { type: "string", 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: "Session the pane held, once known.", }, mode: { type: "string", nullable: true, 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", 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 }, 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", 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.", required: ["items"], properties: { items: { type: "array", items: { $ref: "#/components/schemas/RunHistoryItem" }, }, }, }, RunCwdSuggestion: { type: "object", description: "A suggested working directory. Only directories that still exist on disk are returned.", required: ["kind", "path", "label"], properties: { kind: { type: "string", enum: ["dashboard", "home", "repo", "recent"], 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", example: "/home/dev/projects/my-app" }, label: { type: "string", example: "my-app" }, }, }, RunCwdsResponse: { type: "object", required: ["items"], properties: { items: { type: "array", items: { $ref: "#/components/schemas/RunCwdSuggestion" } }, }, }, RunFilesResponse: { type: "object", description: "File-path autocomplete results for the prompt editor's `@` references — up to 40 paths relative to the given cwd, shortest first.", required: ["items"], properties: { items: { type: "array", items: { type: "string" }, 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 AI tool's CLI is installed, from a fresh catalog detection (the binary is not invoked).", required: ["found", "path"], properties: { found: { type: "boolean", example: true }, path: { type: "string", nullable: true, example: "/home/dev/.local/bin/claude" }, }, }, RunSpawnRequest: { type: "object", 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: ["laneId"], properties: { laneId: { type: "integer", description: "Lane to start the pane for.", example: 5 }, cwd: { type: "string", 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", nullable: true, example: "opus" }, permissionMode: { type: "string", enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], default: "auto", description: "claude only. An unknown value falls back to `auto`.", }, effort: { type: "string", nullable: true, example: "high" }, sandbox: { type: "string", 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", required: ["ok"], properties: { ok: { type: "boolean", enum: [true], example: true }, archived: { type: "string", nullable: true, description: "Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).", }, }, }, }; const paths = { "/api/run": { get: { tags: ["Run"], 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: "Live panes", content: { "application/json": { schema: { $ref: "#/components/schemas/RunListResponse" }, example: { items: [ { id: "agenthub-lane-5", laneId: 5, slot: 0, status: "running", tool: "claude", cwd: "/home/dev/projects/my-app", model: "opus", permissionMode: "auto", effort: null, resumeSessionId: null, sessionId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", worktree: false, startedAt: "2026-10-22T08:00:00.000Z", promptPreview: "Refactor the auth module", }, ], }, }, }, }, 403: ebadOrigin403, }, }, post: { tags: ["Run"], 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, content: { "application/json": { schema: { $ref: "#/components/schemas/RunSpawnRequest" }, example: { laneId: 5, initialPrompt: "Run the tests", permissionMode: "acceptEdits" }, }, }, }, responses: { 201: { description: "Pane started", content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } }, }, 400: errorJson( "Invalid input: EBADLANE (laneId missing), EBADCWD, EBADPROMPT, ENOTOOL, EBADMODEL, EBADEFFORT, EBADSESSION, EBADSANDBOX, EBADPROXY, EUNSUPPORTED, or another E-code from spawnRun." ), 403: { description: "EBADORIGIN (cross-origin) or EFOLDER (cwd is not one of the caller's workspaces).", content: { "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)."), }, }, }, "/api/run/history": { get: { tags: ["Run"], summary: "List persisted run history", 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", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 500, default: 50 }, description: "Maximum number of history rows to return (default 50, clamped to 1–500).", example: 50, }, { name: "laneId", in: "query", required: false, schema: { type: "integer" }, description: "Return only the runs of this lane. Omit for every run.", example: 5, }, { name: "tool", in: "query", required: false, schema: { type: "string" }, description: "Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).", example: "shell", }, ], responses: { 200: { description: "Run history, most recent first", content: { "application/json": { schema: { $ref: "#/components/schemas/RunHistoryResponse" }, example: { items: [ { id: "agenthub-lane-5", session_id: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", 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: "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, }, ], }, }, }, }, 403: ebadOrigin403, }, }, }, "/api/run/history/{id}": { delete: { tags: ["Run"], summary: "Forget a closed Terminal tab", description: "Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.", operationId: "runHistoryForget", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" }, description: "The history row's id (RunHistoryItem.id).", example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z", }, ], responses: { 200: { description: "Removed", content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } }, }, 403: ebadOrigin403, 404: { description: "No closed Terminal tab with that id (ENOTFOUND)", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, 409: { description: "That terminal is still open (ERUNLIVE)", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, }, }, }, "/api/run/cwds": { get: { tags: ["Run"], summary: "Suggest working directories", 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", in: "query", required: false, schema: { type: "string" }, description: "Optional case-insensitive substring filter (also applied client-side).", example: "my-app", }, ], responses: { 200: { description: "Working-directory suggestions", content: { "application/json": { schema: { $ref: "#/components/schemas/RunCwdsResponse" }, example: { items: [ { kind: "dashboard", path: "/Users/dev/AgentHub", 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" }, ], }, }, }, }, 403: ebadOrigin403, }, }, }, "/api/run/files": { get: { tags: ["Run"], summary: "Autocomplete files within a cwd", 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", in: "query", required: false, schema: { type: "string" }, description: "Absolute directory to walk. Must exist; defaults to the dashboard server's cwd when omitted. Invalid values yield 400 EBADCWD.", example: "/Users/dev/projects/my-app", }, { name: "q", in: "query", required: false, schema: { 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", content: { "application/json": { schema: { $ref: "#/components/schemas/RunFilesResponse" }, example: { items: ["package.json", "src/index.ts", "server/routes/run.js"] }, }, }, }, 400: { description: "Invalid cwd (EBADCWD): not a string, not absolute, or does not exist.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "EBADCWD", message: "cwd does not exist: /no/such/dir" } }, }, }, }, 403: ebadOrigin403, }, }, }, "/api/run/binary": { get: { tags: ["Run"], 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", content: { "application/json": { schema: { $ref: "#/components/schemas/RunBinaryResponse" }, example: { found: true, path: "/usr/local/bin/claude" }, }, }, }, 400: errorJson("Unknown tool id (EBADTOOL)."), 403: ebadOrigin403, }, }, }, "/api/run/{id}": { get: { tags: ["Run"], 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", in: "path", required: true, schema: { type: "string" }, description: "Run id (RunHandle.id).", example: "agenthub-lane-5", }, ], responses: { 200: { description: "The pane", content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } }, }, 403: ebadOrigin403, 404: errorJson("No live pane with this id (ENOTFOUND).", { error: { code: "ENOTFOUND", message: "run not found" }, }), }, }, delete: { tags: ["Run"], 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", in: "path", required: true, schema: { type: "string" }, description: "Run id (RunHandle.id).", example: "agenthub-lane-5", }, { name: "archive", in: "query", required: false, schema: { type: "string", enum: ["1"] }, description: "`1` also archives the session the pane held.", }, ], responses: { 200: { description: "Killed", content: { "application/json": { schema: { $ref: "#/components/schemas/RunKillResponse" }, example: { ok: true }, }, }, }, 403: ebadOrigin403, 404: errorJson("No live dashboard pane with this id (ENOTFOUND).", { error: { code: "ENOTFOUND", message: "run not found" }, }), }, }, }, }; module.exports = { tags, schemas, paths };