57f02f0d89
- openapi-extra/run.js describes tmux pane runs only: the removed
POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
`agenthub update` -> `update-check`; help lists `server install-system|tools`
and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
is code Claude runs later), with a test; `command -v` takes its name as $1.
1054 lines
41 KiB
JavaScript
1054 lines
41 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 `<data dir>/reports/<id>/`. 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-<id>-<runId>.<format>`.",
|
|
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 `<cwd>`/`~` 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_<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 <push token>` (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:<source>`; a pushed hook may extend only a session its own source created (EFOREIGN), its `transcript_path` is dropped, and it never touches a lane or a pane.",
|
|
requestBody: json(
|
|
{
|
|
source: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
|
events: {
|
|
type: "array",
|
|
minItems: 1,
|
|
maxItems: 500,
|
|
items: {
|
|
type: "object",
|
|
required: ["hook_type", "data"],
|
|
properties: {
|
|
hook_type: { type: "string" },
|
|
data: { type: "object" },
|
|
tool: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
["source", "events"]
|
|
),
|
|
responses: {
|
|
200: ok("Per-event results.", {
|
|
ok: { type: "boolean" },
|
|
source: { type: "string", example: "push:laptop" },
|
|
stored: { type: "integer" },
|
|
results: { type: "array", items: { type: "object" } },
|
|
}),
|
|
400: errs("EBADSOURCE or EBADBATCH."),
|
|
401: errs("EUNAUTHORIZED."),
|
|
503: errs("EPUSHDISABLED."),
|
|
},
|
|
},
|
|
},
|
|
"/api/artifacts/{id}/installs": {
|
|
get: {
|
|
tags: ["Artifacts"],
|
|
summary: "Who installed an artifact",
|
|
operationId: "listArtifactInstalls",
|
|
description:
|
|
"Newest first (≤ 500). The owner, staff and local mode see every install, anyone else only their own; `paths` (where a skill was written) goes only to the installer and an admin. `unrecorded` counts installs from before this list was kept.",
|
|
parameters: [pathParam("id", "Artifact id.")],
|
|
responses: {
|
|
200: ok("Installs.", {
|
|
items: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
user: { type: "string", nullable: true },
|
|
mine: { type: "boolean" },
|
|
at: { type: "string" },
|
|
replaced: { type: "boolean" },
|
|
paths: { type: "array", items: { type: "string" } },
|
|
},
|
|
},
|
|
},
|
|
unrecorded: { type: "integer" },
|
|
}),
|
|
404: errs("ENOTFOUND."),
|
|
},
|
|
},
|
|
},
|
|
"/api/artifacts/{id}/uninstall": {
|
|
post: {
|
|
tags: ["Artifacts"],
|
|
summary: "Take an installed skill or plugin out of the caller's accounts",
|
|
operationId: "uninstallArtifact",
|
|
description:
|
|
"A skill's folders are moved aside (never deleted); a plugin runs `claude plugin uninstall --scope user --keep-data` as the caller. Allowed while installs are off. Same-origin only.",
|
|
parameters: [pathParam("id", "Artifact id.")],
|
|
responses: {
|
|
200: ok("Removed.", {
|
|
uninstalled: { type: "string" },
|
|
paths: { type: "array", items: { type: "string" } },
|
|
}),
|
|
400: errs("EBADKIND / EBADPLUGIN."),
|
|
404: errs("ENOTFOUND."),
|
|
409: errs("ENOTINSTALLED or EUPGRADE."),
|
|
502: errs("EPLUGIN."),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
module.exports = { tags, schemas, paths };
|