57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
1170 lines
43 KiB
JavaScript
1170 lines
43 KiB
JavaScript
/**
|
||
* @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.
|
||
*
|
||
* 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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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",
|
||
},
|
||
];
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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.",
|
||
content: {
|
||
"application/json": {
|
||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||
example: {
|
||
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
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",
|
||
],
|
||
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: {
|
||
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: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||
description:
|
||
"Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.",
|
||
example: "acceptEdits",
|
||
},
|
||
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,
|
||
},
|
||
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.",
|
||
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: {
|
||
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: {
|
||
type: "string",
|
||
nullable: true,
|
||
description:
|
||
"Spawn/runtime error message if the child failed to start or errored; null otherwise.",
|
||
example: null,
|
||
},
|
||
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: {
|
||
type: "string",
|
||
description:
|
||
"Trailing slice of the child's raw stdout (capped at ~4 KiB) for quick diagnostics.",
|
||
example: '{"type":"result","subtype":"success",...}\n',
|
||
},
|
||
stderrTail: {
|
||
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 },
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
RunListResponse: {
|
||
type: "object",
|
||
description: "All currently-tracked run handles (newest first) plus concurrency telemetry.",
|
||
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.",
|
||
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",
|
||
},
|
||
session_id: {
|
||
type: "string",
|
||
nullable: true,
|
||
description: "Claude session id once known (from system/init), else null.",
|
||
example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||
},
|
||
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: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||
description: "Permission mode recorded at spawn time.",
|
||
example: "acceptEdits",
|
||
},
|
||
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",
|
||
},
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
|
||
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 for spawning a run. Only directories that still exist on disk are returned.",
|
||
required: ["kind", "path", "label"],
|
||
properties: {
|
||
kind: {
|
||
type: "string",
|
||
enum: ["dashboard", "home", "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.",
|
||
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",
|
||
},
|
||
},
|
||
},
|
||
|
||
RunCwdsResponse: {
|
||
type: "object",
|
||
description: "Working-directory suggestions for the Run launcher.",
|
||
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. Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, etc.) 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.",
|
||
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",
|
||
},
|
||
},
|
||
},
|
||
|
||
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"],
|
||
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",
|
||
},
|
||
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",
|
||
},
|
||
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",
|
||
},
|
||
permissionMode: {
|
||
type: "string",
|
||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||
default: "acceptEdits",
|
||
description:
|
||
"Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.",
|
||
example: "acceptEdits",
|
||
},
|
||
},
|
||
},
|
||
|
||
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: {
|
||
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: {
|
||
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",
|
||
},
|
||
},
|
||
},
|
||
|
||
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 },
|
||
},
|
||
},
|
||
};
|
||
|
||
const paths = {
|
||
"/api/run": {
|
||
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.",
|
||
operationId: "runList",
|
||
responses: {
|
||
200: {
|
||
description: "Tracked runs and concurrency telemetry",
|
||
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,
|
||
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: "",
|
||
},
|
||
],
|
||
maxConcurrent: 10000,
|
||
activeCount: 1,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
403: ebadOrigin403,
|
||
},
|
||
},
|
||
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.",
|
||
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",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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: "",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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).",
|
||
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" } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
"/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.",
|
||
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 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.",
|
||
example: 5,
|
||
},
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: "Run history, most recent first",
|
||
content: {
|
||
"application/json": {
|
||
schema: { $ref: "#/components/schemas/RunHistoryResponse" },
|
||
example: {
|
||
items: [
|
||
{
|
||
id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c",
|
||
session_id: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||
mode: "conversation",
|
||
cwd: "/Users/dev/projects/my-app",
|
||
model: "claude-opus-4-8",
|
||
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",
|
||
isLive: false,
|
||
lane_id: null,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
403: ebadOrigin403,
|
||
},
|
||
},
|
||
},
|
||
|
||
"/api/run/cwds": {
|
||
get: {
|
||
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.",
|
||
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/Claude-Code-Agent-Monitor",
|
||
label: "Dashboard server",
|
||
},
|
||
{ kind: "home", path: "/Users/dev", label: "Home" },
|
||
{ 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` 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.",
|
||
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",
|
||
},
|
||
],
|
||
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 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.",
|
||
operationId: "runBinary",
|
||
responses: {
|
||
200: {
|
||
description: "Binary resolution result",
|
||
content: {
|
||
"application/json": {
|
||
schema: { $ref: "#/components/schemas/RunBinaryResponse" },
|
||
example: { found: true, path: "/usr/local/bin/claude" },
|
||
},
|
||
},
|
||
},
|
||
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",
|
||
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.",
|
||
operationId: "runGet",
|
||
parameters: [
|
||
{
|
||
name: "id",
|
||
in: "path",
|
||
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",
|
||
},
|
||
],
|
||
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..." }],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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" } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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.",
|
||
operationId: "runKill",
|
||
parameters: [
|
||
{
|
||
name: "id",
|
||
in: "path",
|
||
required: true,
|
||
schema: { type: "string", format: "uuid" },
|
||
description: "Run id (RunHandle.id).",
|
||
example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c",
|
||
},
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: "Kill issued (or run already terminated).",
|
||
content: {
|
||
"application/json": {
|
||
schema: { $ref: "#/components/schemas/RunKillResponse" },
|
||
example: { ok: true },
|
||
},
|
||
},
|
||
},
|
||
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" } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
module.exports = { tags, schemas, paths };
|