/** * @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."), }, }, }, "/api/artifacts/{id}/claim": { post: { tags: ["Artifacts"], summary: "Take an unowned artifact", operationId: "claimArtifact", description: "Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.", parameters: [pathParam("id", "Artifact id.")], responses: { 200: ok("Claimed.", { item: { type: "object" } }), 400: errs("ELOCALMODE."), 403: errs("EFORBIDDEN: not the suggested user."), 404: errs("ENOTFOUND."), 409: errs("EOWNED: it already has an owner."), }, }, }, "/api/artifacts/{id}/decline": { post: { tags: ["Artifacts"], summary: "Say an unowned artifact offered to you is not yours", operationId: "declineArtifact", description: "Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.", parameters: [pathParam("id", "Artifact id.")], responses: { 200: ok("Declined.", { id: { type: "integer" }, declined: { type: "boolean" } }), 400: errs("ELOCALMODE."), 403: errs("EFORBIDDEN: not the suggested user."), 404: errs("ENOTFOUND."), 409: errs("EOWNED."), }, }, }, "/api/artifacts/{id}/assign": { post: { tags: ["Artifacts"], summary: "Give an unowned artifact to a user (admin)", operationId: "assignArtifact", description: "Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.", parameters: [pathParam("id", "Artifact id.")], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["userId"], properties: { userId: { type: "integer" } }, }, }, }, }, responses: { 200: ok("Assigned.", { item: { type: "object" } }), 400: errs("ELOCALMODE / EBADFIELD."), 403: errs("EFORBIDDEN: not an admin."), 404: errs("ENOTFOUND."), 409: errs("EOWNED."), }, }, }, }; module.exports = { tags, schemas, paths };