Files
AgentHub/client/src/lib/api.ts
T

5537 lines
240 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file api.ts
* @description Defines a set of functions for interacting with the backend API of the agent dashboard application. It includes methods for fetching statistics, managing sessions and agents, retrieving analytics data, handling settings, and managing model pricing. The module abstracts away the details of making HTTP requests and provides a clean interface for the rest of the application to use when communicating with the server.
*
* ## What this module is
* `api.ts` is the single, centralized REST client for the React dashboard. Every page/hook that
* needs data from the Express backend (`server/`) goes through the {@link api} object exported here
* rather than calling `fetch` directly. Keeping all HTTP access in one place gives the app a single
* choke point for authentication, base-path handling, JSON (de)serialization, and error
* normalization, and it keeps the network surface visible and greppable in one file.
*
* ## Layering / where this sits
* The end-to-end data flow of the product is:
*
* Claude Code hooks -> Express API (`server/`) -> SQLite -> WebSocket broadcast -> React UI.
*
* This file covers exactly one hop of that flow: the request/response REST calls the browser makes
* to the Express API. It is deliberately *not* responsible for real-time updates. Live pushes
* (new events, status transitions, run output, import progress, etc.) arrive out-of-band over the
* WebSocket connection and are handled by the `eventBus` / `useWebSocket` layer. A typical page
* therefore does an initial REST `list`/`get` through this module to hydrate, then listens on the
* socket for incremental changes. The two mechanisms are complementary; neither replaces the other.
*
* ## Conventions shared by (almost) every call
* - **Base path.** All paths passed to {@link request} are relative to {@link BASE} ("/api"). The
* Vite dev server proxies "/api" to the Express port in development, and in production the same
* origin serves both the built client and the API, so a relative base works in both modes.
* - **Auth.** When the operator has locked the server down with a `DASHBOARD_TOKEN`, the token is
* attached to every request as the `x-dashboard-token` header. In the default zero-config loopback
* setup there is no token and the header is omitted. See {@link dashboardToken}.
* - **JSON in/out.** Requests default to `Content-Type: application/json`; bodies are hand-serialized
* with `JSON.stringify` at each call site (so the caller controls the exact shape) and responses
* are parsed with `res.json()` and returned as the method's generic `T`.
* - **Errors.** Non-2xx responses are converted into a thrown `Error` by {@link request}; the message
* is the server's structured `error.message` when present, otherwise `HTTP <status>`. Callers get a
* rejected promise they can surface in a toast / error boundary; they never see the raw `Response`.
* - **Pagination.** List endpoints accept `limit`/`offset` and echo them back alongside a `total`.
* Transcript reading is the exception: it paginates by JSONL line number (`after`/`before`) because
* the underlying file grows live and numeric offsets would drift (see {@link api.sessions.transcript}).
* - **Timezone bucketing.** Endpoints that group data by day (`stats`, `analytics`, `pricing.cost`)
* send the browser's `getTimezoneOffset()` as `tz_offset` so "today" and per-day rollups line up
* with the *viewer's* local midnight instead of the server's clock/UTC.
* - **Query-string building.** Optional filters are assembled with `URLSearchParams` and only appended
* when at least one value is present, so a filter-less call hits a clean, cache-friendly URL.
*
* ## Two deliberate escapes from {@link request}
* A couple of operations cannot use the shared JSON wrapper and open-code their own `fetch`:
* 1. {@link api.import.upload} sends `multipart/form-data` (a `FormData` body), which must *not*
* carry the JSON `Content-Type` header, so it calls `fetch` directly.
* 2. {@link api.settings.exportData} returns a *URL string* rather than performing a fetch, because
* the DB export is consumed as an `<a href download>` navigation, not an XHR.
*
* ## Shape of the exports
* The bulk of the module is the {@link api} object: a nested, resource-grouped map of endpoint
* functions whose grouping mirrors the `server/routes/*.js` file layout (sessions, agents, events,
* analytics, settings, workflows, pricing, import, cc-config, run, alerts, webhooks). The remainder
* of the file is the set of exported TypeScript `interface`/`type` declarations describing the
* request bodies and response payloads that are *specific to this client* (many response DTOs are
* imported from `./types`; the ones declared here are the client-only ones, e.g. the CC-config
* explorer shapes, the Run-page process handles, and the import-result shape).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Central typed HTTP client for every REST route; attaches auth token, data-scope `sources` query params, and normalizes error payloads.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./types`
* - `./dataScope`
*
* ## Public surface
* - `dashboardToken` — exported API; see TSDoc on the symbol for behavior.
* - `api` — exported API; see TSDoc on the symbol for behavior.
* - `CcArtifactType` — exported API; see TSDoc on the symbol for behavior.
* - `CcWriteArgs` — exported API; see TSDoc on the symbol for behavior.
* - `CcDeleteArgs` — exported API; see TSDoc on the symbol for behavior.
* - `CcMutationResult` — exported API; see TSDoc on the symbol for behavior.
* - `CcBackup` — exported API; see TSDoc on the symbol for behavior.
* - `CcScope` — exported API; see TSDoc on the symbol for behavior.
* - `CcMdItem` — exported API; see TSDoc on the symbol for behavior.
* - `CcPluginContributions` — exported API; see TSDoc on the symbol for behavior.
* - `CcPlugin` — exported API; see TSDoc on the symbol for behavior.
* - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpServer` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpScope` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpAddArgs` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookEntry` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookSource` — exported API; see TSDoc on the symbol for behavior.
* - `CcSettingsSource` — exported API; see TSDoc on the symbol for behavior.
* - `CcMemoryItem` — exported API; see TSDoc on the symbol for behavior.
* - `CcFileResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcOverview` — exported API; see TSDoc on the symbol for behavior.
* - `CcMarketplace` — exported API; see TSDoc on the symbol for behavior.
* - `CcMarketplacesResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcKeybindingGroup` — exported API; see TSDoc on the symbol for behavior.
* - `CcKeybindings` — exported API; see TSDoc on the symbol for behavior.
* - `CcStatuslineScript` — exported API; see TSDoc on the symbol for behavior.
* - `CcStatusline` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookScripts` — exported API; see TSDoc on the symbol for behavior.
* - `RunMode` — exported API; see TSDoc on the symbol for behavior.
* - `RunStatus` — exported API; see TSDoc on the symbol for behavior.
* - `PermissionMode` — exported API; see TSDoc on the symbol for behavior.
* - `EffortLevel` — exported API; see TSDoc on the symbol for behavior.
* - `RunStartArgs` — exported API; see TSDoc on the symbol for behavior.
* - `RunHandle` — exported API; see TSDoc on the symbol for behavior.
* - `RunListResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CwdSuggestion` — exported API; see TSDoc on the symbol for behavior.
* - `ModelChoice` — exported API; see TSDoc on the symbol for behavior.
* - `EffortChoice` — exported API; see TSDoc on the symbol for behavior.
* - `RUN_EFFORT_CHOICES` — exported API; see TSDoc on the symbol for behavior.
* - `RUN_MODEL_CHOICES` — exported API; see TSDoc on the symbol for behavior.
* - … plus 6 additional exports
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **dashboardToken**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **api**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcArtifactType**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcWriteArgs**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcDeleteArgs**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMutationResult**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcBackup**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcScope**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMdItem**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcPluginContributions**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcPlugin**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcPluginsResponse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMcpServer**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMcpResponse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcHookEntry**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcHookSource**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcSettingsSource**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMemoryItem**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcFileResponse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcOverview**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMarketplace**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcMarketplacesResponse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcKeybindingGroup**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcKeybindings**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcStatuslineScript**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcStatusline**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CcHookScripts**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RunMode**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RunStatus**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **PermissionMode**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **EffortLevel**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RunStartArgs**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RunHandle**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RunListResponse**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CwdSuggestion**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **ModelChoice**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **EffortChoice**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RUN_EFFORT_CHOICES**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RUN_MODEL_CHOICES**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **ImportResult**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RemoteSource**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RemoteSourceInput**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RemoteSourceTestResult**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **RemoteSourceSyncResult**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **ImportBackupResult**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CodexSandbox**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CodexApproval**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
// ─────────────────────────────────────────────────────────────────────────────
// Shared response/entity DTOs. These are the cross-cutting types produced by the
// server and reused across many endpoints (sessions, agents, events, analytics,
// pricing, webhooks, alerts, workflows, transcripts, update status). Types that
// are specific to a single client feature area are declared further down in this
// file instead of being imported here.
// ─────────────────────────────────────────────────────────────────────────────
import type {
Agent,
AlertEvent,
AlertRule,
Analytics,
BudgetStatus,
RulePreview,
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
ToolQuota,
DashboardEvent,
Lane,
LaneChangeRange,
LaneChanges,
LaneBlame,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
LaneCounts,
Project,
LaneGitFacts,
LaneRuntime,
LaneTool,
LaneWorktree,
ModelPricing,
NamedLock,
PipelineStats,
Session,
SessionDrillIn,
SessionStats,
SkillUsage,
Stats,
TranscriptListResult,
TranscriptResult,
UpdateStatusPayload,
WebhookDelivery,
WebhookProvider,
WebhookTarget,
WebhookTestResult,
WebhookType,
WorkflowData,
WorkflowRun,
WorkflowRunsResponse,
WorkflowRunDetail,
SessionSnapshotInfo,
SessionSnapshotPayload,
QueryPage,
ReportDefinition,
ReportInput,
ReportRun,
} from "./types";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
const BASE = "/api";
/**
* Append the current global data-scope (see {@link activeSourcesParam}) as a
* `sources` query param, unless the caller already set one. Called by the
* scoped list/aggregate endpoints (sessions, events, agents, stats, analytics)
* so changing the scope narrows the whole app without every call site threading
* it. `mode: "all"` yields no param, so unscoped installs hit clean URLs.
*/
function applyScope(qs: URLSearchParams): URLSearchParams {
if (!qs.has("sources")) {
const sources = activeSourcesParam();
if (sources) qs.set("sources", sources);
}
return qs;
}
/**
* Optional dashboard auth token (GHSA-gr74-4xfh-6jw9). Only needed when the
* operator binds the server to a LAN and sets DASHBOARD_TOKEN; for the default
* loopback bind there is no token and this returns null (zero-config). Read from
* an injected global first, then localStorage so a LAN user can set it once.
*
* Resolution order (first hit wins):
* 1. `globalThis.__DASHBOARD_TOKEN__` — a value the server can inject into the
* served HTML so an operator-provisioned token is available on first paint
* without any client-side setup.
* 2. `localStorage["dashboard_token"]` — a token the user pasted into the UI
* once; it persists across reloads for that browser.
*
* The whole body is wrapped in try/catch because both `globalThis` access and
* `localStorage` can throw (e.g. storage disabled/blocked in some privacy modes);
* any failure degrades gracefully to "no token" rather than crashing the client.
*
* @returns The resolved token string, or `null` when none is configured/available.
*/
export function dashboardToken(): string | null {
try {
// Prefer a server-injected global (set into the page before the app boots).
const injected = (globalThis as { __DASHBOARD_TOKEN__?: unknown }).__DASHBOARD_TOKEN__;
if (typeof injected === "string" && injected) return injected;
// Fall back to a token the user saved in this browser's localStorage.
const stored = localStorage.getItem("dashboard_token");
return stored && stored.length > 0 ? stored : null;
} catch {
// Storage/global access blocked → behave as an unauthenticated loopback client.
return null;
}
}
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
function ccQuery(scope?: CcScope, cwd?: string): string {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (cwd) qs.set("cwd", cwd);
const q = qs.toString();
return q ? `?${q}` : "";
}
/**
* Shared fetch wrapper used by every method on {@link api}. Prefixes `path`
* with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as
* the `x-dashboard-token` header, and normalizes non-2xx responses into a
* thrown `Error` whose message is the server's `error.message` (falling back
* to `HTTP <status>` when the body isn't JSON or has no message).
*
* This is the workhorse behind the entire {@link api} surface. Centralizing it
* here means individual endpoint methods stay one-liners and never repeat auth,
* header-merging, or error-shaping logic. Two callers intentionally bypass it:
* the multipart upload ({@link api.import.upload}) and the export-URL builder
* ({@link api.settings.exportData}) — see the module overview for why.
*
* Header precedence (later spreads win): the JSON `Content-Type` default is set
* first, then the auth token, then any caller-supplied `options.headers` — so a
* caller can override `Content-Type` if it ever needs to, and per-call headers
* are merged into (not replaced by) the defaults.
*
* @typeParam T The expected parsed JSON shape of a successful response body.
* @param path Path segment appended to `/api` (should start with "/").
* @param options Standard `fetch` options; `headers` are merged, not replaced.
* @returns The parsed JSON response body, typed as `T`.
* @throws {Error} When the response status is not ok (non-2xx). The thrown
* message is `body.error.message` if the error body parsed as JSON and carried
* one, otherwise the literal `HTTP <status>`. The server's own `error.code`
* (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every
* route's error body carries one (see `server/routes/*.js`), and a caller
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** Active login sessions (cookies not expired). */
logins?: number;
lastLoginAt?: string | null;
};
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
username: string;
running: boolean;
/** Did not answer (hung, or backed off). */
stuck?: boolean;
version?: string | null;
pid?: number | null;
startedAt?: number | null;
sessions?: number | null;
/** Workspaces of theirs with an agent working now. */
busy: number;
/** Runs an older tmux than the installed one. */
outdated: boolean;
}
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
export interface MemberTmux {
available: boolean;
installed?: string | null;
/** Calls on an older server already use its own binary (tmux-member installed). */
matched?: boolean;
users: MemberTmuxRow[];
}
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
export interface HostUsageGroup {
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
key: string;
kind: "user" | "host" | "system";
userId?: number;
username?: string;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
memory: number;
memoryPercent: number;
processes: number;
/** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[];
}
/** `GET /api/host` — hardware, load, disks and per-user usage. */
export interface HostStats {
at: string;
hostname: string;
os: { platform: string; release: string; distro: string | null; arch: string };
uptime: number;
cpu: { model: string | null; cores: number; load: number[] };
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: {
device: string;
mount: string;
type: string;
kind: "HDD" | "SSD" | null;
total: number;
used: number;
free: number;
}[];
/** The mount holding the users' homes, or null. */
homesMount: string | null;
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
files: number;
/** The walk timed out or met an unreadable folder: a lower bound. */
partial: boolean;
measuredAt: string;
} | null;
/** GET /api/users/:id — one account plus the workspaces it owns. */
export type UserDetail = {
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
workspaces: { id: number; title: string; cwd: string }[];
};
/** PATCH /api/users/:id — every field optional; only what changed is sent. */
export type UserPatch = {
displayName?: string | null;
email?: string | null;
role?: AuthUser["role"];
disabled?: boolean;
password?: string;
addWorkspaces?: number[];
removeWorkspaces?: number[];
signOut?: true;
};
/** GET /api/me/account — the signed-in account's own information. */
export type MyAccount =
| { mode: "local" }
| {
mode: "server";
user: AuthUser & { disabled: boolean };
workspaces: number;
sessions: number;
logins: number;
lastLoginAt: string | null;
};
/** One Claude pool account as `GET /api/accounts` reports it — never its token. */
export type PoolAccount = {
name: string;
/** `token` = a setup-token; `login` = a real login placed in members' homes. */
kind: "token" | "login";
email: string | null;
/** A login the server could not refresh (`ESTALE`: log in again). */
refreshError: string | null;
addedAt: string;
expiresAt: string | null;
expired: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
export type CodexPoolAccount = {
name: string;
kind: "apikey" | "chatgpt";
addedAt: string;
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
/** A saved switch-user profile that can be moved into the pool (no token). */
export type PoolProfile = {
name: string;
label: string;
email: string | null;
subscriptionType: string | null;
active: boolean;
};
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
export type PoolAccountInput = {
tool: PoolTool;
name: string;
profile?: string;
credentials?: string;
token?: string;
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
at: string;
method: string;
path: string;
session_id: string | null;
reader: string | null;
owner: string | null;
/** Their display names, when set — shown instead of the usernames. */
reader_display?: string | null;
owner_display?: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = {
id: number;
username: string;
role: "admin" | "analyst" | "developer";
/** Optional profile fields — absent from an older server's answers. */
displayName?: string | null;
email?: string | null;
/** When the account's avatar was last set (null = none) — versions its URL. */
avatarAt?: string | null;
createdAt?: string;
};
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
* dedupe, never a cache. Callers must treat the result as read-only. */
const inflight = new Map<string, Promise<unknown>>();
function request<T>(path: string, options?: RequestInit): Promise<T> {
// Only a plain GET: a body, headers or an AbortSignal belongs to one caller.
const plainGet =
!options ||
(Object.keys(options).every((k) => k === "method") &&
(options.method ?? "GET").toUpperCase() === "GET");
if (!plainGet) return send<T>(path, options);
const hit = inflight.get(path);
if (hit) return hit as Promise<T>;
const p = send<T>(path, options).finally(() => inflight.delete(path));
inflight.set(path, p);
return p;
}
async function send<T>(path: string, options?: RequestInit): Promise<T> {
const token = dashboardToken();
// Build the effective header set. Order matters: defaults first so that the
// token and any caller headers can override, and caller headers land last.
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { "x-dashboard-token": token } : {}),
...((options?.headers as Record<string, string>) || {}),
};
// A multipart upload: the browser writes Content-Type with its boundary.
if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
delete headers["Content-Type"];
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
// Try to recover a structured error message from the JSON body; if the body
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
// AuthGate listens and shows the login page again.
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
// Server mode: a role refusal reads the same in every toast, translated.
const message =
body?.error?.code === "EADMIN"
? i18n.t("errors:adminOnly")
: body?.error?.message || `HTTP ${res.status}`;
throw Object.assign(new Error(message), {
code: body?.error?.code,
status: res.status,
// EGITAUTH from a lane git action: which host wants a login.
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
});
}
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
// that missed the route — would otherwise surface as "Unexpected token '<'".
const type = res.headers?.get?.("content-type");
const notJson = () =>
Object.assign(new Error(i18n.t("errors:badResponse")), {
code: "EBADRESPONSE",
status: res.status,
});
if (type && !/json/i.test(type)) throw notJson();
return res.json().catch(() => {
throw notJson();
});
}
/**
* Typed client for every REST endpoint the dashboard consumes, grouped by
* resource (mirroring the `server/routes/*.js` file layout). Every method
* returns a `Promise` resolving to the parsed JSON body via {@link request};
* on a non-2xx response the promise rejects with an `Error`. Real-time updates
* arrive separately over the WebSocket (see {@link eventBus}/`useWebSocket`) -
* this object only covers request/response REST calls.
*
* How to read this object: each top-level key (`updates`, `stats`, `sessions`,
* `agents`, `events`, `analytics`, `settings`, `workflows`, `pricing`, `import`,
* `ccConfig`, `run`, `alerts`, `webhooks`) is one backend resource area. The
* nested functions are the individual endpoints in that area. Because every
* function ultimately calls {@link request}, they all share the same auth,
* JSON-encoding, and error-throwing behavior documented on that helper — the
* per-method docs below focus on the specific route, params, and response shape.
*/
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`), or, with
* `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
*/
export type GitTarget = number | { lane: number; session?: string; source?: number };
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
export type FsPlace = GitTarget | { home: string };
export const isHomePlace = (p: FsPlace): p is { home: string } =>
typeof p === "object" && "home" in p;
/** `/lanes/:id/fs<sub>` (same target as {@link gitUrl}) or `/homes/:who/fs<sub>`. */
function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
if (isHomePlace(place)) {
const qs = new URLSearchParams(q).toString();
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
}
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
}
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
label: string;
readOnly: boolean;
}
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
export interface FsEntry {
name: string;
/** Workspace-relative path. */
path: string;
/** `link` is a symlink; `linkdir` one to a folder inside the workspace. */
type: "dir" | "file" | "link" | "linkdir" | "other";
size: number | null;
mtime: number;
}
export interface FsListing {
path: string;
parent: string | null;
entries: FsEntry[];
truncated: boolean;
}
export interface FsTrashItem {
id: string;
name: string;
laneId: number | null;
path: string;
isDir: boolean;
deletedAt: string;
/** A home's recycle bin only: which of the user's trash folders holds it. */
store?: "home" | "host";
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
home?: string;
owner?: string;
}
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
): GitTarget {
if (source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId;
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
me: () => request<AuthState>("/auth/me"),
/** POST /api/auth/login — sets the httpOnly login cookie. */
login: (username: string, password: string) =>
request<{ user: AuthUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
me: {
/** GET /api/me/prefs — this account's preferences and first-run flag. */
prefs: () => request<UserPrefs>("/me/prefs"),
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
saveInstructions: (instructions: string) =>
request<AgentInstructionsView>("/me/instructions", {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
/** GET /api/me/account — this account's own information (server mode). */
account: () => request<MyAccount>("/me/account"),
/** PATCH /api/me/account — this account's display name and email. */
updateAccount: (body: { displayName?: string | null; email?: string | null }) =>
request<MyAccount>("/me/account", { method: "PATCH", body: JSON.stringify(body) }),
/** POST /api/me/password — this login stays, every other one ends. */
changePassword: (currentPassword: string, newPassword: string) =>
request<{ ok: true }>("/me/password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
}),
/** POST /api/me/sign-out-others — end every login of this account but this one. */
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
request<MyAccount>("/me/avatar", {
method: "PUT",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
host: {
stats: () => request<HostStats>("/host"),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
/** GET /api/users/storage — each user's home size, measured in the background
* (`measuring` = more numbers are coming); `refresh` re-measures every home. */
storage: (refresh = false) =>
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
`/users/storage${refresh ? "?refresh=1" : ""}`
),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
/** POST /api/users — a new account. */
create: (
body: { username: string; password: string; role: AuthUser["role"] },
currentPassword: string
) =>
request<{ user: AuthUser & { disabled: boolean } }>("/users", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
tmux: () => request<MemberTmux>("/users/tmux"),
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
tmuxRestart: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
tmuxRestartIdle: (currentPassword: string) =>
request<{
restarted: string[];
skipped: string[];
failed: { username: string; message: string }[];
}>("/users/tmux-restart-idle", {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
* sign-out in ONE change (never your own role or disabling yourself). */
update: (id: number, body: UserPatch, currentPassword: string) =>
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
}),
/**
* DELETE /api/users/:id — delete the user for good: logins, their
* workspaces' records and (one Linux user per account) their Linux
* account and whole home. Never yourself or the last admin.
*/
remove: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/:id/password — a new password; that user's logins end. */
setPassword: (id: number, password: string, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/password`, {
method: "POST",
body: JSON.stringify({ password, currentPassword }),
}),
/** POST /api/users/:id/workspaces — give that user a workspace. */
assignWorkspace: (id: number, laneId: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/workspaces`, {
method: "POST",
body: JSON.stringify({ laneId, currentPassword }),
}),
},
/** The Claude/Codex account pool every member's new panes run on
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
accountPool: {
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
/** POST /api/accounts — admin, with their OWN password again. */
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
add: (body: PoolAccountInput, currentPassword: string) =>
request<{ account: { name: string } }>("/accounts", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
method: "DELETE",
body: JSON.stringify({ tool, currentPassword }),
}),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
* available" banner and the Settings "check for updates" affordance. Maps to
* `server/routes/updates.js`. */
updates: {
/**
* GET /api/updates/status - cached/last-known result.
*
* Cheap read that returns whatever the server last computed (it does not
* hit the network / run git itself), so the UI can render the update banner
* immediately on load without waiting on a `git fetch`.
*
* @returns {@link UpdateStatusPayload} describing clone-vs-tarball, current
* vs upstream commit, and whether an update is available.
*/
status: () => request<UpdateStatusPayload>("/updates/status"),
/**
* POST /api/updates/check - force a fresh `git fetch` + comparison.
*
* Triggers the server to actually contact the remote and recompute the
* ahead/behind state, then returns the refreshed payload. Sends an empty
* JSON body because it's a POST with no parameters. Invoked when the user
* explicitly clicks "check for updates".
*
* @returns The freshly recomputed {@link UpdateStatusPayload}.
*/
check: () =>
request<UpdateStatusPayload>("/updates/check", {
method: "POST",
body: JSON.stringify({}),
}),
/**
* POST /api/updates/apply - fast-forward, rebuild, and restart.
*
* Only acts when the checkout is fast-forwardable (`tracking_canonical`
* or `fork_or_diverged_tracking`); a 409 response means it declined
* (`applied: false`, `reason` explains why). On success the server
* restarts itself right after responding, so the caller should poll
* `status()` until it answers again rather than expect the connection
* to outlive the call.
*
* @returns {@link UpdateStatusPayload} with `applied` set.
*/
apply: () =>
request<UpdateStatusPayload>("/updates/apply", {
method: "POST",
body: JSON.stringify({}),
}),
},
// ──────────────────────────────── Stats API ────────────────────────────────
/** Lightweight overview counters for the dashboard header. */
stats: {
/**
* GET /api/stats. Sends the browser's UTC offset so `events_today` is
* bucketed by the viewer's local midnight, not the server's.
*
* The `tz_offset` query param carries `Date#getTimezoneOffset()` (minutes
* that local time is *behind* UTC) so the server can compute "today" in the
* viewer's timezone. Polled/refreshed to keep the header counters current.
*
* @returns {@link Stats} — the small set of headline counters (totals,
* active counts, events-today, etc.) shown in the dashboard header.
*/
get: () => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
applyScope(qs);
return request<Stats>(`/stats?${qs.toString()}`);
},
},
// ─────────────────────────────── Sessions API ───────────────────────────────
/** Session CRUD/read, plus their nested agents/events/transcripts. */
sessions: {
/**
* GET /api/sessions/facets - distinct `cwd` values for the filter dropdown.
*
* Powers the "working directory" filter on the Sessions list: the server
* returns the set of distinct project directories seen across sessions so
* the UI can offer them as filter options.
*
* @returns An object with `cwds`: the distinct working-directory strings,
* and `sources`: the distinct machine origins present in the data (always
* includes at least `"local"`), for the data-scope selector.
*/
facets: () => request<{ cwds: string[]; sources: string[] }>("/sessions/facets"),
/**
* GET /api/sessions - paginated, filterable, sortable session list.
*
* Every parameter is optional and only serialized into the query string
* when provided, so an argument-less call returns the default first page.
* `q` is a free-text search; `status`/`cwd` narrow by lifecycle and project
* directory; `sort_by`/`sort_desc` control ordering; `limit`/`offset` page.
* Note the `sort_desc` guard uses `!== undefined` (so an explicit `false`
* is still sent), whereas `limit`/`offset` use truthiness (so `0` is
* treated as "unset" and omitted).
*
* @param params Optional filter/sort/pagination controls.
* @param params.status Lifecycle filter (e.g. "active"/"completed").
* @param params.q Free-text query matched server-side.
* @param params.cwd Restrict to one working directory (see `facets`).
* @param params.sort_by Column to sort by.
* @param params.sort_desc Descending when true; sent even when explicitly false.
* @param params.limit Page size.
* @param params.offset Row offset into the result set.
* @returns `{ sessions, total, limit, offset }` — the page plus the total
* row count and the effective paging window for building pager controls.
*/
list: (params?: {
status?: string;
q?: string;
cwd?: string;
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
tool?: string;
/** The sessions this workspace owns (`sessions.lane_id`). */
lane?: number;
/** `false` = not archived, `true` = archived only; omitted = both. */
archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
from?: string;
to?: string;
sort_by?: string;
sort_desc?: boolean;
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
// Only append params that were actually supplied so the URL stays minimal.
if (params?.status) qs.set("status", params.status);
if (params?.q) qs.set("q", params.q);
if (params?.cwd) qs.set("cwd", params.cwd);
if (params?.tool) qs.set("tool", params.tool);
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
applyScope(qs); // narrow to the active data scope (source machines)
const queryString = qs.toString();
// Omit the "?" entirely when there are no params, for a clean/cacheable URL.
return request<{ sessions: Session[]; total: number; limit: number; offset: number }>(
`/sessions${queryString ? `?${queryString}` : ""}`
);
},
/**
* GET /api/sessions/:id - one session with its agents, events, and any
* Workflow-tool runs launched from it.
*
* The single call that hydrates the Session detail page: it returns the
* session record together with its child agents, its event feed, and any
* Workflow-tool fleet runs associated with it, so the page can render in
* one round-trip. The id is URL-encoded to stay safe for path use.
*
* @param id The session id.
* @returns `{ session, agents, events, workflows }` for the detail view.
*/
/** `events: false` sends `?events=0`: `events` comes back empty instead of
* every event of the session with its full payload. */
get: (id: string, opts: { events?: boolean } = {}) =>
request<{
session: Session;
agents: Agent[];
events: DashboardEvent[];
workflows: WorkflowRun[];
}>(`/sessions/${encodeURIComponent(id)}${opts.events === false ? "?events=0" : ""}`),
/**
* GET /api/sessions/:id/stats - per-session rollups for the detail page.
*
* Aggregate metrics scoped to a single session (token/tool/cost rollups and
* similar), rendered in the session detail header/summary cards.
*
* @param id The session id.
* @returns {@link SessionStats} for that one session.
*/
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
* and the skills / MCP servers it used (a Chat pane's side panel). */
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
/** GET /api/sessions/:id/image — an image the session's Read tool read,
* wherever it lies (local mode only), for an `<img>`. */
readImageUrl: (id: string, path: string) =>
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
/** GET /api/sessions/:id/file — a text file the session itself read or
* wrote, wherever it lies (an output outside the workspace). */
readFile: (id: string, path: string) =>
request<{ text: string; truncated: boolean }>(
`/sessions/${encodeURIComponent(id)}/file?${new URLSearchParams({ path })}`
),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* GET /api/sessions/:id/transcripts - the picker list of available
* transcripts (main agent, subagents, compaction markers) for this session.
*
* Returns the *catalog* of transcripts attached to a session so the UI can
* offer a dropdown/picker (the main-agent transcript, each subagent's own
* transcript, and any compaction boundary markers). The actual message
* content for a chosen transcript is then fetched via
* {@link api.sessions.transcript}.
*
* @param id The session id.
* @returns {@link TranscriptListResult} — the selectable transcript entries.
*/
transcripts: (id: string) =>
request<TranscriptListResult>(`/sessions/${encodeURIComponent(id)}/transcripts`),
/**
* GET /api/sessions/:id/transcript - a page of parsed transcript messages.
* Paginate with `after`/`before` (JSONL line numbers from the previous
* page's `first_line`/`last_line`) rather than `offset` for a live file.
* Pass `agent_id`/`run_id` to read a subagent's transcript instead of the
* main session's.
*
* Why line-number cursors instead of `offset`: the transcript is a JSONL
* file that is still being appended to while the user reads it. A numeric
* `offset` would shift as new lines arrive, causing skips/duplicates; the
* `after`/`before` line-number cursors are stable anchors into the file.
* `limit`/`offset` are still accepted (and forwarded) for callers that want
* simple windowing, but the `after`/`before` cursors are the live-safe path.
* `after`/`before` use a `!= null` guard so line number `0` is still sent.
*
* @param id The session id (owning session of the transcript).
* @param params Optional selectors/pagination.
* @param params.agent_id Read a specific subagent's transcript.
* @param params.run_id Read a specific Workflow-tool run's transcript.
* @param params.limit Max messages to return in this page.
* @param params.offset Legacy numeric offset (prefer after/before live).
* @param params.after Return messages after this JSONL line number.
* @param params.before Return messages before this JSONL line number.
* @returns {@link TranscriptResult} — the page of messages plus the
* `first_line`/`last_line` cursors to feed the next/previous page.
*/
transcript: (
id: string,
params?: {
agent_id?: string;
run_id?: string;
limit?: number;
offset?: number;
after?: number;
before?: number;
}
) => {
const qs = new URLSearchParams();
if (params?.agent_id) qs.set("agent_id", params.agent_id);
if (params?.run_id) qs.set("run_id", params.run_id);
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
// `!= null` so a legitimate line number of 0 is forwarded (0 is falsy).
if (params?.after != null) qs.set("after", String(params.after));
if (params?.before != null) qs.set("before", String(params.before));
const q = qs.toString();
return request<TranscriptResult>(
`/sessions/${encodeURIComponent(id)}/transcript${q ? `?${q}` : ""}`
);
},
},
// ──────────────────────────────── Agents API ────────────────────────────────
agents: {
/**
* GET /api/agents - agent list, optionally filtered by status/session.
*
* Returns spawned agents across the fleet, optionally narrowed to a single
* `session_id` and/or lifecycle `status`, with `limit`/`offset` paging.
* Only supplied params are serialized. Backs the global Agents view and the
* per-session agent lists.
*
* @param params Optional filters/paging.
* @param params.status Lifecycle filter for the agents.
* @param params.session_id Restrict to agents of one session.
* @param params.limit Page size.
* @param params.offset Row offset.
* @returns `{ agents }` — the matching agents (note: no `total` here).
*/
list: (params?: {
status?: string;
session_id?: string;
/** Every agent of these sessions in one call. */
session_ids?: string[];
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.session_id) qs.set("session_id", params.session_id);
if (params?.session_ids) qs.set("session_ids", params.session_ids.join(","));
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
applyScope(qs); // narrow to the active data scope (source machines)
const q = qs.toString();
return request<{ agents: Agent[] }>(`/agents${q ? `?${q}` : ""}`);
},
},
// ──────────────────────────────── Events API ────────────────────────────────
events: {
/**
* GET /api/events - the global cross-session event feed. Array-valued
* filters (`event_type`/`tool_name`/`agent_id`) are OR'd server-side via
* comma-joined query params.
*
* This is the firehose view across all sessions. The multi-valued filters
* are flattened to a single comma-separated query param each (via the local
* `csv` helper); the server treats the members of one param as an OR set.
* `session_id` is special-cased: it accepts either a single string or an
* array (arrays get the same comma-join treatment; a lone string is sent
* as-is). `q` is free-text; `from`/`to` bound the time window. `limit`/
* `offset` use `!= null` guards so `0` is still forwarded.
*
* @param params Optional filters/paging.
* @param params.event_type Event-type names to include (OR'd).
* @param params.tool_name Tool names to include (OR'd).
* @param params.agent_id Agent ids to include (OR'd).
* @param params.session_id One session id, or an array of them (OR'd).
* @param params.q Free-text search across events.
* @param params.from Start of the time window (server-parsed).
* @param params.to End of the time window (server-parsed).
* @param params.limit Page size (0 allowed/forwarded).
* @param params.offset Row offset (0 allowed/forwarded).
* @returns `{ events, limit, offset, total }` — the page and paging metadata.
*/
list: (params?: {
event_type?: string[];
tool_name?: string[];
agent_id?: string[];
session_id?: string | string[];
q?: string;
from?: string;
to?: string;
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
// Collapse a string[] filter into a single comma-joined value, or undefined
// when empty/absent so it is skipped entirely below.
const csv = (v?: string[]) => (v && v.length > 0 ? v.join(",") : undefined);
const et = csv(params?.event_type);
const tn = csv(params?.tool_name);
const ag = csv(params?.agent_id);
// session_id may be a single id or an array; only arrays go through `csv`.
const sid = Array.isArray(params?.session_id) ? csv(params?.session_id) : params?.session_id;
if (et) qs.set("event_type", et);
if (tn) qs.set("tool_name", tn);
if (ag) qs.set("agent_id", ag);
if (sid) qs.set("session_id", sid);
if (params?.q) qs.set("q", params.q);
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
// `!= null` so an explicit 0 page size / offset is still sent.
if (params?.limit != null) qs.set("limit", String(params.limit));
if (params?.offset != null) qs.set("offset", String(params.offset));
applyScope(qs); // narrow to the active data scope (source machines)
const q = qs.toString();
return request<{
events: DashboardEvent[];
limit: number;
offset: number;
total: number;
}>(`/events${q ? `?${q}` : ""}`);
},
/**
* GET /api/events/facets - distinct event/tool names for filter dropdowns.
*
* Supplies the option lists for the Events page's event-type and tool-name
* multi-selects, so the filter UI only offers values that actually occur.
*
* @returns `{ event_types, tool_names }` — the distinct values for each filter.
*/
facets: () => request<{ event_types: string[]; tool_names: string[] }>("/events/facets"),
},
// ─────────────────────────────── Analytics API ──────────────────────────────
/** Chart-oriented usage analytics for the Analytics page. */
analytics: {
/**
* GET /api/analytics. `tz_offset` shifts the daily buckets to local time,
* same convention as {@link api.stats.get}.
*
* Returns the full analytics bundle (time-series and aggregate breakdowns)
* that the Analytics page renders as charts. Because the data is grouped by
* day, the viewer's timezone offset is sent so the daily buckets align to
* the user's local midnight.
*
* @param opts.tool Only sessions of this AI tool (`sessions.tool`).
* @param opts.range Local `from`/`to` days: counts, tokens and cost follow
* it; the daily series keep their own window.
* @returns {@link Analytics} — the chart-ready analytics payload.
*/
get: (opts: { tool?: string; range?: DateRange } = {}) => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.tool) qs.set("tool", opts.tool);
applyRange(qs, opts.range);
applyScope(qs);
return request<Analytics>(`/analytics?${qs.toString()}`);
},
},
// ─────────────────────────────── Settings API ───────────────────────────────
/** Server/DB introspection and destructive maintenance operations for the
* Settings page (info, hooks reinstall, data reset, pricing reset, cleanup). */
settings: {
/**
* GET /api/settings/info - DB size/pragmas, hook install status, server
* process stats, and transcript-cache stats, all in one call.
*
* A single diagnostics snapshot for the Settings page. The large inline
* response type documents exactly what the server reports:
* - `db`: SQLite file path/size, per-table row `counts`, the effective
* `pragmas` (journal mode, synchronous level, auto-vacuum, encoding,
* foreign-key enforcement, busy timeout), and short-window write
* `load_stats` (5-/15-/60-minute rates).
* - `hooks`: whether the dashboard's Claude Code hooks are installed, the
* settings.json path, and a per-hook installed map.
* - `server`: dashboard release `version`, process uptime, Node version,
* platform/arch, live WebSocket connection count, process memory, CPU
* load averages, and host memory/cpu counts.
* - `transcript_cache`: LRU cache occupancy, capacity, hit/miss counts,
* and the currently-cached keys.
*
* @returns The combined diagnostics object described above.
*/
info: () =>
request<{
db: {
path: string;
size: number;
counts: Record<string, number>;
pragmas: {
journal_mode: string;
synchronous: number;
auto_vacuum: number;
encoding: string;
foreign_keys: number;
busy_timeout: number;
};
load_stats: { m5: number; m15: number; h1: number };
};
hooks: {
installed: boolean;
path: string;
hooks: Record<string, boolean>;
/** "plugin" when the enabled agenthub plugin provides the hooks. */
source?: "plugin";
};
server: {
version: string;
uptime: number;
node_version: string;
platform: string;
ws_connections: number;
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
free_mem: number;
cpus: number;
};
transcript_cache: {
size: number;
maxSize: number;
hits: number;
misses: number;
keys: string[];
};
}>("/settings/info"),
/** Get/set the `~/.claude` root the server reads config from. Lets an
* operator point the dashboard at a non-default Claude Code home (e.g. a
* different user profile) without restarting. */
claudeHome: {
/**
* GET /api/settings/claude-home - the currently configured Claude home path.
* @returns `{ claude_home }` — the absolute path the server reads config from.
*/
get: () => request<{ claude_home: string; env_owned?: boolean }>("/settings/claude-home"),
/**
* PUT /api/settings/claude-home - repoint the server at a new Claude home.
* @param path New absolute `~/.claude` root the server should read from.
* @returns `{ ok, claude_home }` — the accepted path.
*/
set: (path: string) =>
request<{
ok: boolean;
claude_home: string;
env_owned?: boolean;
}>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }),
},
/** The Codex home: same shape as `claudeHome`. */
codexHome: {
/** GET /api/settings/codex-home */
get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"),
/** PUT /api/settings/codex-home */
set: (path: string) =>
request<{
ok: boolean;
codex_home: string;
env_owned: boolean;
}>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }),
},
/** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a
* click; Codex asks the user to trust them before they run. */
codexHooks: {
/** GET /api/settings/codex-hooks */
get: () => request<CodexHooksStatus>("/settings/codex-hooks"),
/** POST /api/settings/codex-hooks — install, or reinstall */
install: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "POST" }),
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
},
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
* by default; a change never starts or stops the running server. */
autostart: {
/** GET /api/settings/autostart */
get: () => request<AutostartStatus>("/settings/autostart"),
/** PUT /api/settings/autostart */
set: (enabled: boolean) =>
request<AutostartStatus>("/settings/autostart", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** AgentHub's icon in the OS status area — on by default; a change starts
* or stops the icon process, never the server. */
tray: {
/** GET /api/settings/tray */
get: () => request<TrayStatus>("/settings/tray"),
/** PUT /api/settings/tray */
set: (enabled: boolean) =>
request<TrayStatus>("/settings/tray", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** POST /api/settings/quit — close every workspace terminal and stop the
* server until `agenthub start` (or a boot/login with autostart on). */
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
/** POST /api/settings/uninstall — remove AgentHub from this machine, its
* database included, in a detached worker (scripts/uninstall.js). */
uninstall: () =>
request<{ ok: true; log: string }>("/settings/uninstall", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
/** GET /api/settings/privacy */
get: () => request<PrivacySettings>("/settings/privacy"),
/** PUT /api/settings/privacy — any subset of the two fields */
save: (data: { redact_secrets?: boolean; ignore_cwd?: string[] }) =>
request<PrivacySettings & { ok: boolean }>("/settings/privacy", {
method: "PUT",
body: JSON.stringify(data),
}),
},
/** The daily database backup (`server/lib/db-backup.js`). */
backup: {
/** GET /api/settings/backup */
get: () => request<BackupState>("/settings/backup"),
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
save: (data: { dir?: string; hour?: number; keep?: number }) =>
request<BackupState & { ok: boolean }>("/settings/backup", {
method: "PUT",
body: JSON.stringify(data),
}),
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
/** GET /api/settings/push-token */
get: () => request<PushTokenStatus>("/settings/push-token"),
/** POST /api/settings/push-token — a new token; the old one stops working */
rotate: () =>
request<PushTokenStatus & { token: string }>("/settings/push-token", { method: "POST" }),
/** DELETE /api/settings/push-token — push ingest off */
revoke: () => request<PushTokenStatus>("/settings/push-token", { method: "DELETE" }),
},
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
* only on a click; agy runs it with no trust step. */
antigravityHooks: {
/** GET /api/settings/antigravity-hooks */
get: () => request<AntigravityHooksStatus>("/settings/antigravity-hooks"),
/** POST /api/settings/antigravity-hooks — install, or reinstall */
install: () =>
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "POST" }),
/** DELETE /api/settings/antigravity-hooks — AgentHub's named hook only */
remove: () =>
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "DELETE" }),
},
/**
* POST /api/settings/clear-data - DESTRUCTIVE: wipes sessions/agents/
* events/etc. from the dashboard DB. Returns per-table row counts deleted.
*
* Empties the dashboard's own SQLite tables (it does not touch the user's
* on-disk Claude Code transcripts). Guarded behind an explicit confirmation
* in the Settings UI. Sent as a bodyless POST.
*
* @returns `{ ok, cleared }` where `cleared` maps each table name to the
* number of rows deleted from it.
*/
clearData: () =>
request<{ ok: boolean; cleared: Record<string, number> }>("/settings/clear-data", {
method: "POST",
}),
/**
* POST /api/settings/reimport - re-scan `~/.claude/projects` and
* backfill anything not already in the DB.
*
* Additive counterpart to `clearData`: re-reads the on-disk project
* transcripts and inserts anything missing, leaving existing rows in place.
*
* @returns `{ ok, imported, skipped, errors }` — counts of newly imported
* rows, already-present rows skipped, and parse/import failures.
*/
reimport: () =>
request<{ ok: boolean; imported: number; skipped: number; errors: number }>(
"/settings/reimport",
{ method: "POST" }
),
/**
* POST /api/settings/reinstall-hooks - re-write the dashboard's Claude
* Code hook entries into `~/.claude/settings.json`.
*
* Repairs/re-applies the hook wiring that feeds this dashboard (used when a
* user has edited settings.json or the install drifted). Returns the
* post-install hook status so the UI can reflect the new state.
*
* @returns `{ ok, hooks }` where `hooks.installed` and `hooks.hooks`
* describe the resulting per-hook install state.
*/
reinstallHooks: () =>
request<{ ok: boolean; hooks: { installed: boolean; hooks: Record<string, boolean> } }>(
"/settings/reinstall-hooks",
{ method: "POST" }
),
/**
* POST /api/settings/reset-pricing - restore the built-in default
* {@link ModelPricing} rules, discarding any custom edits.
*
* Wipes user-customized pricing rules and reseeds the shipped defaults;
* returns the resulting rule set so the Pricing UI can re-render.
*
* @param scope - `"claude"` resets only `claude-…` rows; omitted, every row.
* @returns `{ ok, pricing }` — the full rule list now in effect.
*/
resetPricing: (scope?: "claude") =>
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
method: "POST",
...(scope && { body: JSON.stringify({ scope }) }),
}),
/**
* Direct download URL for GET /api/settings/export (a full DB dump);
* not fetched via {@link request} since it's used as an `<a href>`.
*
* Returns a *string*, not a promise: this is the one endpoint the client
* navigates to (an anchor download) rather than XHR-fetching, so no auth
* header can be attached here — the export route is expected to be reachable
* with the same-origin session the page already has.
*
* @returns The absolute-on-origin URL (`/api/settings/export`) to link to.
*/
exportData: () => `${BASE}/settings/export`,
/**
* POST /api/settings/import (multipart) - restore a bundle previously
* produced by {@link exportData}. Idempotent and non-destructive: sessions
* already present are skipped whole, so importing a backup (or another
* machine's export) never duplicates or overwrites live data.
*
* Like {@link api.import.upload}, this bypasses {@link request} to send a
* `multipart/form-data` body (field name "file") and let the browser set the
* boundary. No auth token is attached (local/zero-config flow).
*
* @param file The `.json` export file the user selected.
* @returns {@link ImportBackupResult} — per-table restore counts.
* @throws {Error} On a non-2xx response, mirroring {@link request}.
*/
importData: async (file: File): Promise<ImportBackupResult> => {
const form = new FormData();
form.append("file", file, file.name);
const res = await fetch(`${BASE}/settings/import`, { method: "POST", body: form });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
}
return res.json();
},
/**
* POST /api/settings/cleanup - DESTRUCTIVE: marks sessions idle longer
* than `abandon_hours` as "abandoned", and purges rows older than
* `purge_days`. Returns counts of what was abandoned/purged.
*
* Maintenance sweep with two independent knobs, both optional: sessions that
* have been idle beyond `abandon_hours` are transitioned to the "abandoned"
* state, and any rows older than `purge_days` are hard-deleted. The params
* object is always sent (it's required by the signature) so the server can
* apply its own defaults for any omitted field.
*
* @param params Retention thresholds.
* @param params.abandon_hours Idle-hours cutoff after which a session is abandoned.
* @param params.purge_days Age-in-days cutoff after which rows are purged (whole number ≥ 1, else 400).
* @param params.dry_run Count only; nothing is changed.
* @returns `{ ok, abandoned, purged_sessions, purged_events, purged_agents }`
* — how many records each part of the sweep affected.
*/
cleanup: (params: { abandon_hours?: number; purge_days?: number; dry_run?: boolean }) =>
request<{
ok: boolean;
/** Set when `dry_run` was sent: the counts are what WOULD change. */
dry_run?: boolean;
abandoned: number;
purged_sessions: number;
purged_events: number;
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
method: "POST",
body: JSON.stringify(body),
}),
},
// ─────────────────────────────── Workflows API ──────────────────────────────
/** Events-derived workflow intelligence (`get`/`session`) plus Workflow-tool
* fleet runs ingested from on-disk journals (`runs`/`run`). */
workflows: {
/**
* GET /api/workflows - the full {@link WorkflowData} panel bundle,
* optionally filtered to "active"/"completed" sessions.
*
* The `status` filter is only appended when it is set *and* not the sentinel
* "all" (which means "no filter"), keeping the default URL param-free.
*
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @param tool Optional AI tool id (`sessions.tool`).
* @param range Optional local date range: sessions started in it.
* @param sections Optional subset of the bundle to compute (`?sections=`);
* the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
* or just the named sections of it.
*/
get: <K extends keyof WorkflowData = keyof WorkflowData>(
status?: string,
tool?: string,
range?: DateRange,
sections?: readonly K[]
) => {
const qs = new URLSearchParams();
if (status && status !== "all") qs.set("status", status);
if (tool) qs.set("tool", tool);
applyRange(qs, range);
if (sections?.length) qs.set("sections", sections.join(","));
const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
},
/**
* GET /api/workflows/session/:id - single-session drill-in (agent tree,
* tool timeline, swim lanes).
*
* Detailed per-session workflow reconstruction derived from that session's
* events, powering the drill-in visualizations.
*
* @param id The session id to reconstruct.
* @returns {@link SessionDrillIn} — the agent tree, tool timeline, and lanes.
*/
session: (id: string) =>
request<SessionDrillIn>(`/workflows/session/${encodeURIComponent(id)}`),
/**
* GET /api/workflows/skill-usage — how often each skill was invoked and how
* much each plugin is used (its skills plus its own MCP tools).
*
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @returns {@link SkillUsage} — per-skill rows and per-plugin totals.
*/
skillUsage: (status?: string) =>
request<SkillUsage>(
`/workflows/skill-usage${status && status !== "all" ? `?status=${status}` : ""}`
),
// Workflow-tool runs (issue #167) - fleets ingested from on-disk journals.
// These two endpoints cover fleets that emit no hooks: the server reads their
// run journals off disk (see server/lib/workflow-ingest.js) instead of the
// usual hook -> event pipeline, so they live under their own routes.
/**
* GET /api/workflows/runs - paginated Workflow-tool run list.
*
* Same "skip "all"" convention for `status` as {@link api.workflows.get};
* `limit`/`offset` use `!= null` guards so `0` is forwarded.
*
* @param params Optional filters/paging.
* @param params.status Lifecycle filter; "all"/omitted means no filter.
* @param params.session_id Restrict to runs of one session.
* @param params.limit Page size (0 allowed).
* @param params.offset Row offset (0 allowed).
* @returns {@link WorkflowRunsResponse} — the page of runs plus paging info.
*/
runs: (params?: { status?: string; session_id?: string; limit?: number; offset?: number }) => {
const qs = new URLSearchParams();
if (params?.status && params.status !== "all") qs.set("status", params.status);
if (params?.session_id) qs.set("session_id", params.session_id);
if (params?.limit != null) qs.set("limit", String(params.limit));
if (params?.offset != null) qs.set("offset", String(params.offset));
const q = qs.toString();
return request<WorkflowRunsResponse>(`/workflows/runs${q ? `?${q}` : ""}`);
},
/**
* GET /api/workflows/runs/:runId - one run with its inner agents/events.
*
* The Workflow-tool analog of {@link api.sessions.get}: expands a single
* ingested run into its nested agents and events for a detail view.
*
* @param runId The Workflow-tool run id.
* @returns {@link WorkflowRunDetail} — the run plus its agents and events.
*/
run: (runId: string) =>
request<WorkflowRunDetail>(`/workflows/runs/${encodeURIComponent(runId)}`),
},
// ─────────────────────────────── Pricing API ────────────────────────────────
/** {@link ModelPricing} rule CRUD, plus computed cost totals. */
pricing: {
/**
* GET /api/pricing - all configured pricing rules.
* @returns `{ pricing }` — the full list of {@link ModelPricing} rules.
*/
list: () => request<{ pricing: ModelPricing[] }>("/pricing"),
/**
* PUT /api/pricing - create a new rule or overwrite the one matching
* `data.model_pattern` (the primary key).
*
* Upsert semantics keyed on `model_pattern`: an existing rule with the same
* pattern is replaced, otherwise a new one is created. The `updated_at`
* field is server-managed, hence it is `Omit`ted from the argument type.
*
* @param data A {@link ModelPricing} rule minus its server-set `updated_at`.
* @returns `{ pricing }` — the single upserted rule as persisted.
*/
upsert: (data: Omit<ModelPricing, "updated_at">) =>
request<{ pricing: ModelPricing }>("/pricing", {
method: "PUT",
body: JSON.stringify(data),
}),
/**
* DELETE /api/pricing/:pattern - remove a rule; usage matching it then
* falls through to a less-specific rule or `unpriced_models`.
*
* The pattern is URL-encoded because model patterns can contain characters
* (slashes, brackets) that are unsafe in a path segment.
*
* @param pattern The `model_pattern` primary key of the rule to delete.
* @returns `{ ok }` — success flag.
*/
delete: (pattern: string) =>
request<{ ok: boolean }>(`/pricing/${encodeURIComponent(pattern)}`, {
method: "DELETE",
}),
/**
* GET /api/pricing/cost - total cost across every session, priced with
* each day's rate (respects time-limited intro pricing).
*
* Because pricing rules can carry date-bounded intro rates, the server
* prices each day's usage with that day's effective rate; `tz_offset` keeps
* the day boundaries aligned to the viewer's timezone.
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns; `tool`: one AI tool's.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool);
// `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range);
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
quota: (force = false) =>
request<{ tools: ToolQuota[]; checkedAt: string }>(
`/pricing/quota${force ? "?force=1" : ""}`
),
/**
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of
* the session's start date.
*
* Single-session cost, priced using the rate in effect on that session's
* start date. `tz_offset` again aligns date handling to the viewer.
*
* @param sessionId The session to price.
* @returns {@link CostResult} — the cost breakdown for that one session.
*/
sessionCost: (sessionId: string) =>
request<CostResult>(
`/pricing/cost/${encodeURIComponent(sessionId)}?tz_offset=${new Date().getTimezoneOffset()}`
),
},
// ──────────────────────────────── Import API ────────────────────────────────
/** Transcript import: on-disk scan/rescan, an explicit path scan, or a
* browser file upload - all three converge on the same {@link ImportResult}
* shape and stream progress via the `import.progress` WS message. */
import: {
/**
* GET /api/import/guide - platform-specific instructions and constraints
* (default projects dir, supported extensions, upload limits) shown on
* first run / in the Import wizard.
*
* Returns everything the Import wizard needs to render its guidance without
* hard-coding platform details in the client: the OS `platform`, the
* default projects directory (raw + display form + existence + a quick
* `{ projects, jsonl_files }` count), the recommended `archive_command`,
* the accepted file extensions, upload size/count caps, and an ordered list
* of wizard `steps`.
*
* @returns The import-guide payload described above.
*/
guide: () =>
request<{
platform: string;
default_projects_dir: string;
default_projects_dir_display: string;
default_projects_dir_exists: boolean;
default_projects_dir_stats: { projects: number; jsonl_files: number };
archive_command: string;
supported_extensions: string[];
max_upload_bytes: number;
max_upload_files: number;
steps: { id: string; title: string; body: string }[];
}>("/import/guide"),
/**
* POST /api/import/rescan - re-scan the default projects directory.
*
* Kicks off an import over the server's default `~/.claude/projects` dir.
* Progress is pushed live over the `import.progress` WebSocket message; the
* returned {@link ImportResult} is the final tally (`source: "default"`).
*
* @returns {@link ImportResult} — the completed-scan summary.
*/
rescan: () => request<ImportResult>("/import/rescan", { method: "POST" }),
/**
* POST /api/import/scan-path - scan an arbitrary directory for
* Claude Code project transcripts.
*
* Like `rescan` but over a user-provided directory (`source: "path"`),
* useful for importing an archive extracted somewhere non-default.
*
* @param path Absolute directory to scan for transcripts.
* @returns {@link ImportResult} — the completed-scan summary for that path.
*/
scanPath: (path: string) =>
request<ImportResult>("/import/scan-path", {
method: "POST",
body: JSON.stringify({ path }),
}),
/**
* POST /api/import/upload (multipart) - import a set of user-selected
* transcript files. Bypasses {@link request} to use `FormData`.
*
* This is one of the two deliberate escapes from {@link request}: a
* `multipart/form-data` body must be built with `FormData` and must let the
* browser set its own `Content-Type` (with the multipart boundary), so this
* hand-rolls `fetch` and reproduces `request`'s error-normalization inline.
* Each selected `File` is appended under the field name "files" (preserving
* its original filename). Result `source` is "upload".
*
* Note: no auth token is attached here (unlike {@link request}); the upload
* route is used in the local/zero-config import flow.
*
* @param files The user-selected transcript files to upload.
* @returns {@link ImportResult} — the completed-upload summary.
* @throws {Error} On a non-2xx response, mirroring {@link request}: the
* server's `error.message` if present, else `HTTP <status>`.
*/
upload: async (files: File[]): Promise<ImportResult> => {
const form = new FormData();
// Append each file under the repeated "files" field, keeping its filename.
for (const f of files) form.append("files", f, f.name);
// Do NOT set Content-Type manually: the browser adds the multipart boundary.
const res = await fetch(`${BASE}/import/upload`, { method: "POST", body: form });
if (!res.ok) {
// Same error-shaping contract as request(), duplicated because this call
// intentionally does not route through the JSON wrapper.
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
}
return res.json();
},
},
// ─────────────────────────────── CC-Config API ──────────────────────────────
/** Read/write access to on-disk Claude Code configuration - skills, agents,
* commands, output styles, plugins, MCP servers, hooks, settings.json,
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
* script - for the dashboard's "CC Config" explorer/editor pages. */
ccConfig: {
/**
* GET /api/cc-config/overview - counts of every artifact kind, for the
* explorer's landing page.
* @returns {@link CcOverview} — filesystem roots plus per-kind counts.
*/
overview: () => request<CcOverview>("/cc-config/overview"),
/**
* GET /api/cc-config/skills - user and/or project SKILL.md files.
*
* The optional `scope` is appended as `?scope=` only when provided; omitting
* it lets the server apply its default scope. Same pattern for the sibling
* list endpoints below (`agents`, `commands`, `outputStyles`).
*
* @param scope Optional {@link CcScope} ("user"|"project"|"all") filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — the {@link CcMdItem} summaries for each skill.
*/
skills: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/skills${ccQuery(scope, cwd)}`),
/**
* GET /api/cc-config/agents - user and/or project subagent definitions.
* @param scope Optional {@link CcScope} filter.
* @returns `{ items }` — {@link CcMdItem} summaries for each subagent.
*/
agents: (scope?: CcScope) =>
request<{ items: CcMdItem[] }>(`/cc-config/agents${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/cc-config/commands - user and/or project slash commands.
* @param scope Optional {@link CcScope} filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — {@link CcMdItem} summaries for each command.
*/
commands: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/commands${ccQuery(scope, cwd)}`),
/**
* GET /api/cc-config/output-styles.
* @param scope Optional {@link CcScope} filter.
* @returns `{ items }` — {@link CcMdItem} summaries for each output style.
*/
outputStyles: (scope?: CcScope) =>
request<{ items: CcMdItem[] }>(`/cc-config/output-styles${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/cc-config/plugins - installed marketplace plugins and what
* each one contributes (skills/agents/commands/hooks counts).
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
*/
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
/**
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
*/
mcp: () => request<CcMcpResponse>("/cc-config/mcp"),
/**
* POST /api/cc-config/mcp/add - add an MCP server via `claude mcp add`.
* Either `url` (http/sse) or `command`+`args` (stdio) must be given, not
* both. `scope` is required — `user` or `project` only, never the
* settings union's third `project-local` value.
* @returns {@link CcCliResult} — the CLI's stdout/stderr on success.
*/
mcpAdd: (args: CcMcpAddArgs) =>
request<CcCliResult>("/cc-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/mcp/remove - remove an MCP server via
* `claude mcp remove`. `scope` narrows which config layer to remove
* from; omit it to let the CLI find the server wherever it exists.
*/
mcpRemove: (name: string, scope?: CcMcpScope) =>
request<CcCliResult>("/cc-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name, scope }),
}),
/**
* GET /api/cc-config/hooks - hook entries from every settings.json layer.
* @returns `{ items }` — one {@link CcHookSource} per settings layer.
*/
hooks: () => request<{ items: CcHookSource[] }>("/cc-config/hooks"),
/**
* GET /api/cc-config/settings - raw settings.json files by scope.
* @returns `{ items }` — one {@link CcSettingsSource} per scope layer.
*/
settings: () => request<{ items: CcSettingsSource[] }>("/cc-config/settings"),
/**
* GET /api/cc-config/memory - CLAUDE.md files plus per-project auto-memory.
* @returns `{ items }` — {@link CcMemoryItem}s for CLAUDE.md + auto-memory.
*/
memory: () => request<{ items: CcMemoryItem[] }>("/cc-config/memory"),
/**
* GET /api/cc-config/file - raw contents of one config file by absolute path.
*
* The absolute path is passed as a URL-encoded `path` query param (not a
* path segment) so arbitrary filesystem paths survive intact.
*
* @param absPath Absolute path of the config file to read.
* @returns {@link CcFileResponse} — file text (possibly truncated) + metadata.
*/
file: (absPath: string) =>
request<CcFileResponse>(`/cc-config/file?path=${encodeURIComponent(absPath)}`),
/**
* PUT /api/cc-config/file - create/overwrite a config artifact; the
* server writes a backup of any previous content first.
*
* The write is always preceded server-side by a timestamped backup (see
* {@link api.ccConfig.backups}), so edits are reversible.
*
* @param args {@link CcWriteArgs} — scope/type/name/content (+project for auto-memory).
* @returns {@link CcMutationResult} — the written path, backup path, and
* whether a new file was `created`.
*/
write: (args: CcWriteArgs) =>
request<CcMutationResult>("/cc-config/file", {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/project-memory - create `CLAUDE.md` in a project the
* dashboard has never run in.
*
* Unlike {@link api.ccConfig.write}, whose "project" scope always means the
* dashboard server's own directory, this one writes into the directory you
* name. It only ever creates: an existing CLAUDE.md comes back as a 409.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/cc-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/cc-config/file - remove a config artifact (also backed up).
*
* Note the DELETE carries a JSON body ({@link CcDeleteArgs}) identifying the
* artifact by scope/type/name rather than encoding it in the URL.
*
* @param args {@link CcDeleteArgs} — which artifact to delete.
* @returns {@link CcMutationResult} — the deleted path and its backup path.
*/
delete: (args: CcDeleteArgs) =>
request<CcMutationResult>("/cc-config/file", {
method: "DELETE",
body: JSON.stringify(args),
}),
/**
* GET /api/cc-config/marketplaces - registered plugin marketplaces.
* @returns {@link CcMarketplacesResponse} — the registry path/status + items.
*/
marketplaces: () => request<CcMarketplacesResponse>("/cc-config/marketplaces"),
/**
* GET /api/cc-config/keybindings - parsed `keybindings.json`.
* @returns {@link CcKeybindings} — grouped key/action bindings + file metadata.
*/
keybindings: () => request<CcKeybindings>("/cc-config/keybindings"),
/**
* PUT /api/cc-config/keybindings - overwrite the user's `keybindings.json`
* from a structured list of groups. The server backs the file up first and
* preserves any top-level metadata (`$schema`/`$docs`), replacing only the
* `bindings` array.
*
* @param groups Full set of {@link CcKeybindingGroup}s to persist.
* @returns {@link CcMutationResult} — the written path, backup path, and
* whether the file was newly `created`.
*/
writeKeybindings: (groups: CcKeybindingGroup[]) =>
request<CcMutationResult>("/cc-config/keybindings", {
method: "PUT",
body: JSON.stringify({ groups }),
}),
/**
* GET /api/cc-config/statusline - active statusline config + scripts.
* @returns {@link CcStatusline} — the active config plus discovered scripts.
*/
statusline: () => request<CcStatusline>("/cc-config/statusline"),
/**
* GET /api/cc-config/hook-scripts - shell scripts referenced by hooks.
* @returns {@link CcHookScripts} — the hooks dir and the scripts found in it.
*/
hookScripts: () => request<CcHookScripts>("/cc-config/hook-scripts"),
/**
* GET /api/cc-config/backups - timestamped backups written by `write`/
* `delete`, optionally filtered by scope/artifact type.
*
* Delegates query-string building to the module-level
* {@link requestBackupsHelper} (extracted purely so its logic is
* independently unit-referenceable).
*
* @param params Optional `{ scope, type }` filter.
* @returns `{ items }` — the matching {@link CcBackup} entries.
*/
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
requestBackupsHelper(params),
/**
* GET /api/cc-config/profiles - saved auth profiles + the live account.
* @returns `{ items, active }` — {@link CcProfile}s and the currently
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
*/
profiles: () =>
request<{
items: CcProfile[];
active: CcAccountIdentity | null;
/** Set when the live token belongs to a different account than the
* one `.claude.json` names; `active` is then the token's owner. */
mismatch?: { configEmail: string | null } | null;
}>("/cc-config/profiles"),
/**
* POST /api/cc-config/profiles - snapshot the logged-in account as a
* profile. The identifier and initial label are derived from the account;
* saving an account another profile already holds fails `EDUPLICATE`.
*/
saveProfile: () =>
request<{ ok: true; name: string; label: string } & CcAccountIdentity>(
"/cc-config/profiles",
{ method: "POST", body: JSON.stringify({}) }
),
/**
* PATCH /api/cc-config/profiles - change a profile's display label. The
* `name` identifier does not move; spaces and diacritics are allowed.
*/
renameProfile: (name: string, label: string) =>
request<{ ok: true; name: string; label: string }>("/cc-config/profiles", {
method: "PATCH",
body: JSON.stringify({ name, label }),
}),
/**
* POST /api/cc-config/profiles/activate - make a saved profile the account
* the local `claude` CLI runs as. The outgoing account is re-snapshotted
* into its own profile first (`savedOutgoing`).
*/
activateProfile: (name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null; written: string[] }>(
"/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) }
),
/**
* POST /api/cc-config/profiles/usage - how much of this profile's account
* quota is already spent.
*
* A POST because answering it is a write: a saved profile's access token has
* almost always expired, so the server refreshes it against Anthropic first
* and the refresh rotates the credentials stored on disk. That is also why
* this is never called on a timer — only when the user asks for one profile.
* No token material crosses the boundary in either direction.
*/
profileUsage: (name: string) =>
request<CcProfileUsage>("/cc-config/profiles/usage", {
method: "POST",
body: JSON.stringify({ name }),
}),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", {
method: "DELETE",
body: JSON.stringify({ name }),
}),
/**
* GET /api/cc-config/plugins/available - every plugin offered by a
* registered marketplace, from `claude plugin list --available --json`.
* @returns `{ installed, available }` — the CLI's own inventory.
*/
pluginsAvailable: () => request<CcAvailablePlugins>("/cc-config/plugins/available"),
/**
* POST /api/cc-config/plugins/install - install `spec` via the `claude`
* CLI. `scope` defaults to `"user"` server-side.
* @returns {@link CcCliResult} — the CLI's stdout/stderr on success.
*/
installPlugin: (spec: string, scope?: CcCliScope) =>
request<CcCliResult>("/cc-config/plugins/install", {
method: "POST",
body: JSON.stringify({ spec, scope }),
}),
/**
* POST /api/cc-config/plugins/action - enable/disable/update/uninstall an
* installed plugin by its `<name>@<marketplace>` key.
*
* `reload` additionally types `/reload-plugins` + `/reload-skills` into the
* live Claude Code lane panes, but only when the action itself succeeded —
* "update one, reload that one", as opposed to the batch below which
* reloads once after everything.
*/
pluginAction: (key: string, action: CcPluginAction, reload?: boolean) =>
request<CcCliResult & { reloaded?: CcReloadResult }>("/cc-config/plugins/action", {
method: "POST",
body: JSON.stringify({ key, action, reload }),
}),
/**
* POST /api/cc-config/reload - tell the Claude Code sessions the dashboard
* has open to re-read plugins and/or skills from disk.
*
* Both slash commands are scoped to the session that runs them, so this
* types them into the live lane panes over tmux. Sessions running outside
* the dashboard cannot be reached and must run the command themselves.
*/
reload: (kinds?: CcReloadKind[]) =>
request<{ ok: true } & CcReloadResult>("/cc-config/reload", {
method: "POST",
body: JSON.stringify({ kinds }),
}),
/** GET /api/cc-config/plugins/update-all - progress of the current or last batch. */
updateAllStatus: () =>
request<{ batch: CcUpdateAllBatch | null }>("/cc-config/plugins/update-all"),
/**
* POST /api/cc-config/plugins/update-all - update every marketplace, then
* every installed plugin, then reload the open sessions.
*
* Returns as soon as the detached worker is spawned: the batch ends with
* AgentHub itself when the dashboard runs from the plugin cache, and that
* step restarts this server. Follow it with {@link updateAllStatus}.
*/
updateAll: () =>
request<CcUpdateAllStarted>("/cc-config/plugins/update-all", {
method: "POST",
body: JSON.stringify({}),
}),
/**
* POST /api/cc-config/marketplaces/add - register a marketplace from a
* GitHub `owner/repo`, an http(s) URL, or an absolute local path.
*/
addMarketplace: (source: string, scope?: CcCliScope) =>
request<CcCliResult>("/cc-config/marketplaces/add", {
method: "POST",
body: JSON.stringify({ source, scope }),
}),
/** POST /api/cc-config/marketplaces/action - update or remove a marketplace. */
marketplaceAction: (name: string, action: CcMarketplaceAction) =>
request<CcCliResult>("/cc-config/marketplaces/action", {
method: "POST",
body: JSON.stringify({ name, action }),
}),
/**
* PATCH /api/cc-config/settings - merge individual keys into one
* settings.json layer (this is also how hooks are edited, as
* `set: { "hooks.<Event>": [...] }` in settings.json's nested shape).
*
* Only the named keys are touched: the server re-reads the file
* unredacted, so keys the UI never displayed survive untouched. A value
* containing the `<redacted>` placeholder is rejected.
*
* @param args Target `scope` plus dotted-path `set` and/or `unset`.
* @returns {@link CcSettingsPatchResult} — path, backup path, changed keys.
*/
patchSettings: (args: CcSettingsPatchArgs) =>
request<CcSettingsPatchResult>("/cc-config/settings", {
method: "PATCH",
body: JSON.stringify(args),
}),
},
// ─────────────────────────────── Codex-Config API ──────────────────────────────
/** Read/write access to on-disk Codex CLI configuration - skills, agents,
* rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and
* marketplaces - for the dashboard's "Codex Config" explorer/editor pages. */
codexConfig: {
/**
* GET /api/codex-config/overview - counts of every artifact kind, for the
* explorer's landing page.
* @returns {@link CodexOverview} — filesystem roots plus per-kind counts.
*/
overview: () => request<CodexOverview>("/codex-config/overview"),
/**
* GET /api/codex-config/skills - user and/or project skill definitions.
*
* The optional `scope` is appended as `?scope=` only when provided; omitting
* it lets the server apply its default scope.
*
* @param scope Optional {@link CodexScope} ("user"|"project"|"all") filter.
* @returns `{ skills }` — the {@link CodexSkill} items for each skill.
*/
skills: (scope?: CodexScope) =>
request<{ skills: CodexSkill[] }>(`/codex-config/skills${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/agents - user and/or project agent definitions.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ agents }` — {@link CodexAgent} items for each agent.
*/
agents: (scope?: CodexScope) =>
request<{ agents: CodexAgent[] }>(`/codex-config/agents${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/rules - user and/or project rules files.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ rules }` — {@link CodexRule} items for each rule file.
*/
rules: (scope?: CodexScope) =>
request<{ rules: CodexRule[] }>(`/codex-config/rules${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/memory - AGENTS.md files at both scopes.
* @returns `{ memory }` — {@link CodexMemoryFile} items.
*/
memory: () => request<{ memory: CodexMemoryFile[] }>("/codex-config/memory"),
/**
* GET /api/codex-config/config - config.toml files at both scopes.
* @returns `{ config }` — {@link CodexConfigFile} items.
*/
config: () => request<{ config: CodexConfigFile[] }>("/codex-config/config"),
/**
* GET /api/codex-config/file - raw contents of one config file by absolute path.
*
* The absolute path is passed as a URL-encoded `path` query param (not a
* path segment) so arbitrary filesystem paths survive intact.
*
* @param path Absolute path of the config file to read.
* @returns {@link CodexFileResponse} — file text + metadata.
*/
file: (path: string) =>
request<CodexFileResponse>(`/codex-config/file?path=${encodeURIComponent(path)}`),
/**
* PUT /api/codex-config/file - create/overwrite a config artifact; the
* server writes a backup of any previous content first.
*
* @param args {@link CodexWriteArgs} — scope/type/name/content.
* @returns {@link CodexMutationResult} — the written path and whether a new file was created.
*/
write: (args: CodexWriteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/project-memory - create `AGENTS.md` in a project
* the dashboard has never run in. The Codex twin of
* {@link api.ccConfig.createProjectMemory}, same create-only rule.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/codex-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
*
* @param args {@link CodexDeleteArgs} — which artifact to delete.
* @returns {@link CodexMutationResult} — the deleted path and its backup path.
*/
remove: (args: CodexDeleteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "DELETE",
body: JSON.stringify(args),
}),
/**
* GET /api/codex-config/backups - timestamped backups written by `write`/
* `remove`, optionally filtered by scope/artifact type.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries.
*/
backups: (params?: { scope?: "user" | "project"; type?: CodexArtifactType }) =>
requestCodexBackupsHelper(params),
/**
* GET /api/codex-config/mcp - configured MCP servers from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ mcp }` — {@link CodexMcpServer} items.
*/
mcp: async (): Promise<{ mcp: CodexMcpServer[] }> => {
const res = await request<{ mcp: unknown }>("/codex-config/mcp");
return { mcp: Array.isArray(res.mcp) ? (res.mcp as CodexMcpServer[]) : [] };
},
/**
* GET /api/codex-config/plugins - installed plugins from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ plugins }` — {@link CodexPlugin} items.
*/
plugins: async (): Promise<{ plugins: CodexPlugin[] }> => {
const res = await request<{ plugins: unknown }>("/codex-config/plugins");
return { plugins: Array.isArray(res.plugins) ? (res.plugins as CodexPlugin[]) : [] };
},
/**
* GET /api/codex-config/marketplaces - registered marketplaces from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ marketplaces }` — {@link CodexMarketplace} items.
*/
marketplaces: async (): Promise<{ marketplaces: CodexMarketplace[] }> => {
const res = await request<{ marketplaces: unknown }>("/codex-config/marketplaces");
return {
marketplaces: Array.isArray(res.marketplaces)
? (res.marketplaces as CodexMarketplace[])
: [],
};
},
/**
* POST /api/codex-config/mcp/add - add an MCP server via the `codex` CLI.
* @param args {@link CodexMcpAddArgs} for `codex mcp add`.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpAdd: (args: CodexMcpAddArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/mcp/remove - remove an MCP server via the `codex` CLI.
* @param name The MCP server name to remove.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpRemove: (name: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name }),
}),
/**
* POST /api/codex-config/plugins/action - add or remove a plugin via the `codex` CLI.
* @param args {@link CodexPluginActionArgs} with `spec` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
pluginAction: (args: CodexPluginActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/plugins/action", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/marketplaces/add - register a marketplace via the `codex` CLI.
* @param url The marketplace URL to register.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAdd: (url: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/add", {
method: "POST",
body: JSON.stringify({ url }),
}),
/**
* POST /api/codex-config/marketplaces/action - update or remove a marketplace via the `codex` CLI.
* @param args {@link CodexMarketplaceActionArgs} with `name` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAction: (args: CodexMarketplaceActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/action", {
method: "POST",
body: JSON.stringify(args),
}),
},
/** AgentHub's own git credentials (issue #45), shared by every user of this
* install. No response ever carries a password — only `hasToken`. */
gitCredentials: {
/** GET /api/git-credentials */
list: () => request<{ items: GitCredential[] }>("/git-credentials"),
/** POST /api/git-credentials - save, or replace the one for that host. */
save: (body: GitCredentialInput) =>
request<{ items: GitCredential[] }>("/git-credentials", {
method: "POST",
body: JSON.stringify(body),
}),
/** DELETE /api/git-credentials/:protocol/:host */
remove: (protocol: string, host: string) =>
request<{ items: GitCredential[] }>(
`/git-credentials/${encodeURIComponent(protocol)}/${encodeURIComponent(host)}`,
{ method: "DELETE" }
),
},
/**
* The workspace File Explorer (`/api/lanes/:id/fs/*`, server/lib/file-explorer.js).
* Every write runs as the folder's account; a taken name answers 409
* EEXISTS with `names` unless `conflict` settles it.
*/
files: {
/** GET /api/homes — the homes this user may open (their own; others' read-only for an admin). */
homes: () => request<{ homes: FsHome[] }>("/homes"),
/** One file's download URL, on a workspace or a home. */
downloadUrl: (t: FsPlace, path: string) =>
isHomePlace(t)
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
list: (t: FsPlace, path = "") =>
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
mkdir: (t: FsPlace, path: string) =>
request<{ path: string }>(fsUrl(t, "/mkdir"), {
method: "POST",
body: JSON.stringify({ path }),
}),
newFile: (t: FsPlace, path: string) =>
request<{ path: string }>(fsUrl(t, "/newfile"), {
method: "POST",
body: JSON.stringify({ path }),
}),
rename: (t: FsPlace, path: string, name: string) =>
request<{ path: string }>(fsUrl(t, "/rename"), {
method: "POST",
body: JSON.stringify({ path, name }),
}),
move: (t: FsPlace, paths: string[], dest: string, conflict?: FsConflict) =>
request<{ moved: string[] }>(fsUrl(t, "/move"), {
method: "POST",
body: JSON.stringify({ paths, dest, conflict }),
}),
/** Copy into `t`'s `dest` — from `t` itself, or from workspace `fromLane`. */
copy: (
t: FsPlace,
paths: string[],
dest: string,
opts: { fromLane?: number; fromHome?: string; conflict?: FsConflict } = {}
) =>
request<{ copied: string[] }>(fsUrl(t, "/copy"), {
method: "POST",
body: JSON.stringify({ paths, dest, ...opts }),
}),
/** Into the account's trash (restorable). */
remove: (t: FsPlace, paths: string[]) =>
request<{ trashed: string[] }>(fsUrl(t, "/delete"), {
method: "POST",
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
/** Every Trash the caller may open, merged (an admin: every user's). */
allTrash: () =>
request<{
items: FsTrashItem[];
errors?: { home: string; owner: string; message: string }[];
keepDays?: number;
}>("/homes/trash"),
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
body: JSON.stringify(store ? { id, store } : { id }),
}),
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
method: "POST",
body: JSON.stringify(store ? { id, store } : { id }),
}),
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
zipUrl: (t: FsPlace, paths: string[]) => {
const q = new URLSearchParams();
for (const p of paths) q.append("p", p);
return `${BASE}${fsUrl(t, "/zip", q)}`;
},
/**
* Stream `file` to `path` (sub-folders made as needed), reporting upload
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
*/
upload: (
t: FsPlace,
path: string,
file: Blob,
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) =>
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
path?: string;
skipped?: boolean;
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as { path: string; skipped?: boolean });
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(file);
}),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),
/**
* Make the SSH key; `replace` swaps an existing one for a new pair.
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
*/
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
}),
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
removeKey: (type?: "ed25519" | "rsa") =>
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
removeKeyById: (id: string) =>
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
/**
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
* the user): for the host itself, or with `alias` one more account on it
* (`ssh://<alias>/…`); `key` = the only key it uses.
*/
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
/** DELETE a login by its host (the host's own rule) or its alias. */
removeSshUser: (from: string) =>
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) =>
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
saveAuthor: (body: { name: string; email: string }) =>
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
},
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
snapshots: {
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
/** Freeze a session; `filePaths` redacts paths before anything is stored. */
create: (sessionId: string, opts?: { filePaths?: boolean }) =>
request<{ snapshot: SessionSnapshotInfo }>("/snapshots", {
method: "POST",
body: JSON.stringify({
session_id: sessionId,
redact: { file_paths: opts?.filePaths === true },
}),
}),
get: (token: string) =>
request<{ snapshot: SessionSnapshotInfo; payload: SessionSnapshotPayload }>(
`/snapshots/${encodeURIComponent(token)}`
),
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
},
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
if (params.kind) qs.set("kind", params.kind);
if (params.q) qs.set("q", params.q);
const s = qs.toString();
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
},
settings: () => request<ArtifactSettings>("/artifacts/settings"),
setInstallsEnabled: (installsEnabled: boolean) =>
request<{ installsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
body: JSON.stringify({ installsEnabled }),
}),
/** Documents a workspace's sessions wrote, to pin. */
candidates: (laneId: number) =>
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
/** Skill folders the caller may share (their own, and the workspace's). */
skills: (laneId?: number) =>
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", {
method: "POST",
body: JSON.stringify(body),
}),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", {
method: "POST",
body: JSON.stringify(body),
}),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", {
method: "POST",
body: JSON.stringify(body),
}),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
/** Read a marketplace (git link, owner/repo or marketplace.json link) and list its plugins. */
inspectMarket: (market: string) =>
request<MarketplaceInfo>("/artifacts/plugin/inspect", {
method: "POST",
body: JSON.stringify({ market }),
}),
/** A skill written in the dialog: SKILL.md gets `name`/`description` in its frontmatter. */
writeSkill: (body: { name: string; description: string; content: string; title?: string }) =>
request<{ item: Artifact; items: Artifact[] }>("/artifacts/skill/new", {
method: "POST",
body: JSON.stringify(body),
}),
/** Skills from files: one Markdown file, a folder, several files, a .zip/.skill, or
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
const form = new FormData();
form.append("files", file, file.name);
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
update: (
id: number,
body: {
title?: string;
description?: string;
shared?: boolean;
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
market?: string;
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (download) qs.set("download", "1");
const s = qs.toString();
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
},
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`,
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
importBundle: (file: File) =>
request<{ pet: PetView }>("/pets/import", {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/pets/upload — one PNG, JPEG, GIF or WebP image. */
upload: (file: File) =>
request<{ pet: PetView }>(`/pets/upload?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/pets/featured/:id — download a featured pet from codex-pets.net. */
installFeatured: (id: string) =>
request<{ pet: PetView }>(`/pets/featured/${encodeURIComponent(id)}`, { method: "POST" }),
remove: (id: string) =>
request<{ ok: true }>(`/pets/${encodeURIComponent(id)}`, { method: "DELETE" }),
},
aiTools: {
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
list: () => request<AiToolsView>("/ai-tools"),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
update: (id: string, body: AiToolUpdateBody) =>
request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
/** GET /api/ai-tools/:id/models - the models the tool itself offers. */
models: (id: string) => request<AiToolModels>(`/ai-tools/${encodeURIComponent(id)}/models`),
/** GET /api/ai-tools/:id/usage - how many sessions the tool ran, and their cost. */
usage: (id: string) =>
request<{ sessions: number; cost: number }>(`/ai-tools/${encodeURIComponent(id)}/usage`),
/** GET /api/ai-tools/:id/config - the config files the tool's catalog file lists. */
config: (id: string) =>
request<{ files: AiToolConfigFile[] }>(`/ai-tools/${encodeURIComponent(id)}/config`),
/** GET /api/ai-tools/:id/config/:index - one config file's text and sha. */
readConfig: (id: string, index: number) =>
request<{ path: string; format: AiToolConfigFile["format"]; text: string; sha: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`
),
/** PUT /api/ai-tools/:id/config/:index - save over `baseSha` (409 ESTALE otherwise). */
saveConfig: (id: string, index: number, text: string, baseSha: string) =>
request<{ sha: string; backup: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`,
{ method: "PUT", body: JSON.stringify({ text, baseSha }) }
),
/** POST /api/ai-tools/:id/install - start the catalog's install job. */
install: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
method: "POST",
}),
/** POST /api/ai-tools/:id/update - start the catalog's update job. */
updateTool: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/update`, {
method: "POST",
}),
/** POST /api/ai-tools/update-all - one job updating every tool with an update. */
updateAll: () => request<{ job: AiToolJob }>("/ai-tools/update-all", { method: "POST" }),
/** GET /api/ai-tools/job - the current or last job, or null. */
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
},
// ──────────────────────────────── AI Proxy API ──────────────────────────────
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
* new session can run on instead of the vendor's own API. Managed in the
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
* keeps the stored one, which is what the edit form always does: it cannot
* display a key, so it has none to send back.
*/
update: (name: string, body: Partial<AiProxyInput>) =>
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
* for Claude Code and for Codex, before a session finds out it does not.
*/
test: (body: AiProxyTestInput) =>
request<AiProxyTestResult>("/ai-proxies/test", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-proxies/:name - also removes the session files that
* copy its key.
*/
remove: (name: string) =>
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
},
// ────────────────────────────────── Run API ─────────────────────────────────
/** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/run"),
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
* version, and this platform's upgrade advice when it is too old. */
tmuxAvailable: () =>
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
"/run/tmux"
),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
* adds the folders too, as `dir/`. */
files: (cwd: string, q?: string, dirs = false) => {
const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q);
if (dirs) qs.set("dirs", "1");
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
* server's machine; the returned path is what gets pasted into the pane. */
dropImage: (file: Blob) =>
request<{ path: string }>("/run/drop-image", {
method: "POST",
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
* run id) saves it where THAT workspace's panes can read it. */
dropFile: (file: File, lane?: number | string | null) => {
const laneId =
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
const qs = new URLSearchParams({ name: file.name });
if (laneId != null) qs.set("laneId", String(laneId));
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
});
},
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** GET /api/run/:id - one run's current handle. */
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
* (null while a menu hides it). */
permissionMode: (id: string) =>
request<{ mode: PermissionMode | "dontAsk" | null }>(
`/run/${encodeURIComponent(id)}/permission-mode`
),
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
* step read back) to `mode`; 409 when it cannot be reached. */
setPermissionMode: (id: string, mode: PermissionMode) =>
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
method: "POST",
body: JSON.stringify({ mode }),
}),
/** DELETE /api/run/:id - kill the tmux session. `archive` (the UI's Stop)
* also archives the session it held; Sleep leaves it listed to resume. */
kill: (id: string, opts?: { archive?: boolean }) =>
request<{ ok: true; archived?: string | null }>(
`/run/${encodeURIComponent(id)}${opts?.archive ? "?archive=1" : ""}`,
{ method: "DELETE" }
),
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
* killed tmux, crash), newest first; the caller's own workspaces only. */
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
/** POST /api/run/interrupted/dismiss - stop offering these sessions. */
dismissInterrupted: (sessionIds: string[]) =>
request<{ dismissed: number }>("/run/interrupted/dismiss", {
method: "POST",
body: JSON.stringify({ sessionIds }),
}),
},
// ────────────────────────────────── Alerts API ──────────────────────────────
/** Alert rule CRUD plus the fired-alert feed and acknowledgement. */
alerts: {
/**
* GET /api/alerts - fired-alert feed, newest first.
*
* `unacked` is sent as the literal string "true" only when truthy (to show
* just the outstanding alerts); `limit`/`offset` page the feed and are only
* appended when set.
*
* @param params Optional filters/paging.
* @param params.unacked When true, return only unacknowledged alerts.
* @param params.limit Page size.
* @param params.offset Row offset.
* @returns `{ alerts, total, unacked, limit, offset }` — the page plus the
* total and outstanding-unacked counts for badge rendering.
*/
list: (params?: { unacked?: boolean; limit?: number; offset?: number }) => {
const qs = new URLSearchParams();
if (params?.unacked) qs.set("unacked", "true");
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
const q = qs.toString();
return request<{
alerts: AlertEvent[];
total: number;
unacked: number;
limit: number;
offset: number;
}>(`/alerts${q ? `?${q}` : ""}`);
},
/**
* POST /api/alerts/:id/ack - acknowledge a single fired alert.
*
* Note the id is a numeric alert-event id interpolated directly into the
* path (fired-alert ids are numeric, unlike the string ids used elsewhere).
*
* @param id Numeric id of the fired alert to acknowledge.
* @returns `{ alert }` — the updated {@link AlertEvent} (now acknowledged).
*/
ack: (id: number) => request<{ alert: AlertEvent }>(`/alerts/${id}/ack`, { method: "POST" }),
/**
* POST /api/alerts/ack-all - acknowledge every unacked alert at once.
* @returns `{ ok: true, acknowledged }` — count of alerts just acknowledged.
*/
ackAll: () =>
request<{ ok: true; acknowledged: number }>("/alerts/ack-all", { method: "POST" }),
/** CRUD for the alert rule definitions themselves (not the fired events).
* Rules describe *when* to fire; the endpoints above deal with alerts that
* have already fired. */
rules: {
/**
* GET /api/alerts/rules - list every configured alert rule.
* @returns `{ rules }` — the full set of {@link AlertRule} definitions.
*/
list: () => request<{ rules: AlertRule[] }>("/alerts/rules"),
/**
* POST /api/alerts/rules - create a new alert rule.
*
* `rule_type` and `config` are typed against {@link AlertRule} so the body
* matches the rule kind; `enabled` and `cooldown_seconds` are optional and
* server-defaulted when omitted.
*
* @param rule The new rule definition (name, type, config, optional flags).
* @returns `{ rule }` — the created {@link AlertRule} as persisted.
*/
create: (rule: {
name: string;
rule_type: AlertRule["rule_type"];
config: AlertRule["config"];
enabled?: boolean;
cooldown_seconds?: number;
}) =>
request<{ rule: AlertRule }>("/alerts/rules", {
method: "POST",
body: JSON.stringify(rule),
}),
/**
* PATCH /api/alerts/rules/:id - partially update an existing rule.
*
* Accepts any subset of the mutable fields (`name`/`config`/`enabled`/
* `cooldown_seconds`); unspecified fields are left unchanged. Note
* `rule_type` is intentionally not patchable (a rule's kind is fixed).
*
* @param id The rule id to update.
* @param patch Partial set of mutable fields to change.
* @returns `{ rule }` — the updated {@link AlertRule}.
*/
update: (
id: string,
patch: Partial<Pick<AlertRule, "name" | "config" | "enabled" | "cooldown_seconds">>
) =>
request<{ rule: AlertRule }>(`/alerts/rules/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
/**
* DELETE /api/alerts/rules/:id - remove an alert rule.
* @param id The rule id to delete.
* @returns `{ ok: true }` — success flag.
*/
remove: (id: string) =>
request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/alerts/rules/preview - what a rule WOULD have fired over the
* last `days` (default 7) of recorded history. Saves and fires nothing.
*/
preview: (body: {
rule_type: AlertRule["rule_type"];
config: AlertRule["config"];
cooldown_seconds?: number;
days?: number;
}) =>
request<RulePreview>("/alerts/rules/preview", {
method: "POST",
body: JSON.stringify(body),
}),
},
},
/** Spend budgets — `budget` alert rules — with their current-period spend. */
budgets: {
/** GET /api/budgets/status */
status: () => request<{ budgets: BudgetStatus[] }>("/budgets/status"),
},
/** Query Explorer (`GET /api/query`, `server/routes/query.js`). */
query: {
/** One page of the filtered rows; `params` are the page's filters. */
page: (params: URLSearchParams) =>
request<QueryPage>(`/query?${applyScope(new URLSearchParams(params)).toString()}`),
/** A download link for the whole filtered set (≤ `export_cap` rows). */
exportUrl: (params: URLSearchParams, format: "csv" | "json") => {
const qs = applyScope(new URLSearchParams(params));
qs.delete("limit");
qs.delete("offset");
qs.set("format", format);
return `${BASE}/query?${qs.toString()}`;
},
},
/** Scheduled reports (`/api/reports`, `server/lib/reports.js`). */
reports: {
list: () => request<{ reports: ReportDefinition[] }>("/reports"),
create: (body: ReportInput) =>
request<{ report: ReportDefinition }>("/reports", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: Partial<ReportInput>) =>
request<{ report: ReportDefinition }>(`/reports/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/reports/${id}`, { method: "DELETE" }),
run: (id: number) => request<{ run: ReportRun }>(`/reports/${id}/run`, { method: "POST" }),
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
/** A run's file, as a download link. */
fileUrl: (id: number, runId: number, format: string) =>
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
},
// ───────────────────────────────── Webhooks API ─────────────────────────────
/** Outbound webhook target CRUD, provider metadata, test sends, and the
* per-target delivery log. */
webhooks: {
/**
* GET /api/webhooks - configured targets (secrets/URLs redacted).
*
* Sensitive fields (secret, and often the full URL) are redacted server-side
* before being returned to the UI list.
*
* @returns `{ targets }` — the configured {@link WebhookTarget}s (redacted).
*/
list: () => request<{ targets: WebhookTarget[] }>("/webhooks"),
/**
* GET /api/webhooks/providers - supported provider types and their
* form-field schemas, for the "Add webhook" dialog.
*
* Drives a dynamic form: each {@link WebhookProvider} advertises which
* fields (url/secret/headers/config) it needs so the dialog can render the
* right inputs per provider type.
*
* @returns `{ providers }` — the supported provider descriptors.
*/
providers: () => request<{ providers: WebhookProvider[] }>("/webhooks/providers"),
/**
* POST /api/webhooks - create a new target.
*
* `type` selects the {@link WebhookType} provider; `url`/`secret`/`headers`/
* `config` supply provider-specific delivery settings; `rule_ids` scopes the
* target to fire only for those alert rules (all optional except name/type).
*
* @param target The new target definition.
* @returns `{ target }` — the created {@link WebhookTarget} (redacted).
*/
create: (target: {
name: string;
type: WebhookType;
url?: string;
enabled?: boolean;
secret?: string;
headers?: Record<string, string>;
config?: Record<string, string>;
rule_ids?: string[];
}) =>
request<{ target: WebhookTarget }>("/webhooks", {
method: "POST",
body: JSON.stringify(target),
}),
/**
* PATCH /api/webhooks/:id - partially update a target.
*
* All fields optional; only supplied ones change. `secret` accepts `null`
* (distinct from omitted) to explicitly clear a stored secret. `type` is not
* patchable here — a target's provider kind is fixed at creation.
*
* @param id The target id to update.
* @param patch Partial set of fields to change (`secret: null` clears it).
* @returns `{ target }` — the updated {@link WebhookTarget} (redacted).
*/
update: (
id: string,
patch: {
name?: string;
url?: string;
enabled?: boolean;
secret?: string | null;
headers?: Record<string, string>;
config?: Record<string, string>;
rule_ids?: string[];
}
) =>
request<{ target: WebhookTarget }>(`/webhooks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
/**
* DELETE /api/webhooks/:id - remove a target.
* @param id The target id to delete.
* @returns `{ ok: true }` — success flag.
*/
remove: (id: string) =>
request<{ ok: true }>(`/webhooks/${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/webhooks/:id/test - send a synchronous test payload; not
* recorded in the delivery log.
*
* Fires an immediate test delivery so the user can validate credentials/URL
* from the config dialog; the result is returned inline and deliberately
* excluded from the persisted delivery history.
*
* @param id The target id to test.
* @returns {@link WebhookTestResult} — the synchronous send outcome.
*/
test: (id: string) =>
request<WebhookTestResult>(`/webhooks/${encodeURIComponent(id)}/test`, { method: "POST" }),
/**
* GET /api/webhooks/:id/deliveries - paginated delivery history for one target.
*
* The persisted log of real (non-test) deliveries for one target, paged with
* `limit`/`offset` (appended only when provided).
*
* @param id The target id whose history to read.
* @param params Optional `{ limit, offset }` paging.
* @returns `{ deliveries, limit, offset }` — the page of {@link WebhookDelivery}s.
*/
deliveries: (id: string, params?: { limit?: number; offset?: number }) => {
const qs = new URLSearchParams();
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
const q = qs.toString();
return request<{ deliveries: WebhookDelivery[]; limit: number; offset: number }>(
`/webhooks/${encodeURIComponent(id)}/deliveries${q ? `?${q}` : ""}`
);
},
},
// ───────────────────────────── Remote Sources API ────────────────────────────
/** Remote (SSH) machines whose Claude Code history this dashboard pulls in.
* Maps to `server/routes/remote-sources.js`; see also the global data-scope
* selector ({@link "./dataScope"}) which decides which sources are shown. */
remoteSources: {
/**
* GET /api/remote-sources — list every configured source with live status.
* @returns `{ sources }` — the {@link RemoteSource} rows (config + status).
*/
list: () => request<{ sources: RemoteSource[] }>("/remote-sources"),
/**
* POST /api/remote-sources — add a source. No secrets are sent; auth defers
* to the host's SSH stack (see the route/lib docs).
* @param data {@link RemoteSourceInput} — label + ssh destination (+ options).
* @returns `{ source }` — the created {@link RemoteSource}.
*/
create: (data: RemoteSourceInput) =>
request<{ source: RemoteSource }>("/remote-sources", {
method: "POST",
body: JSON.stringify(data),
}),
/**
* PATCH /api/remote-sources/:id — partial update (any subset of fields).
* @param id The source id.
* @param data Partial {@link RemoteSourceInput}.
* @returns `{ source }` — the updated {@link RemoteSource}.
*/
update: (id: string, data: Partial<RemoteSourceInput>) =>
request<{ source: RemoteSource }>(`/remote-sources/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
/**
* DELETE /api/remote-sources/:id — remove a source. Pass `purge` to also
* delete the sessions it imported (destructive); default detaches them to
* `local`.
* @param id The source id.
* @param purge When true, also delete this source's imported sessions.
* @returns `{ ok, purged }` — success flag and count of purged sessions.
*/
remove: (id: string, purge = false) =>
request<{ ok: boolean; purged: number }>(
`/remote-sources/${encodeURIComponent(id)}${purge ? "?purge=true" : ""}`,
{ method: "DELETE" }
),
/**
* POST /api/remote-sources/:id/test — probe SSH connectivity + remote dir.
* @param id The source id.
* @returns {@link RemoteSourceTestResult}.
*/
test: (id: string) =>
request<RemoteSourceTestResult>(`/remote-sources/${encodeURIComponent(id)}/test`, {
method: "POST",
}),
/**
* POST /api/remote-sources/:id/sync — pull the remote history now. Progress
* also streams over the `import.progress` / `remote_source.status` WS
* messages; this resolves with the final counters.
* @param id The source id.
* @returns {@link RemoteSourceSyncResult}.
*/
sync: (id: string) =>
request<RemoteSourceSyncResult>(`/remote-sources/${encodeURIComponent(id)}/sync`, {
method: "POST",
}),
/**
* POST /api/remote-sources/sync-all — sync every enabled source now
* (sequential; per-source failures isolated).
* @returns `{ ok, synced, results }` — one entry per enabled source.
*/
syncAll: () =>
request<{
ok: boolean;
synced: number;
results: Array<{ id: string; ok: boolean; error?: string }>;
}>("/remote-sources/sync-all", { method: "POST" }),
},
/** Lanes grouped by git repo, with each repo's worktrees (server/lib/projects.js). */
projects: {
/** GET /api/projects — read on demand by the sidebar tree, never polled. */
list: () => request<{ projects: Project[] }>("/projects"),
/** POST /api/projects/refresh — drop the cached roots/worktree lists, then list. */
refresh: () => request<{ projects: Project[] }>("/projects/refresh", { method: "POST" }),
},
// ──────────────────────────────── Lanes API ────────────────────────────────
/** Agent work lanes: durable units of parallel work, one cwd per lane.
* Maps to `server/routes/lanes.js`. */
lanes: {
/**
* GET /api/lanes — list all lanes with pipeline progress and liveness.
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
*/
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
/**
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
* @param body The cwd and optional title. When `cwd` is omitted the server
* falls back to the user's home directory — useful for lanes whose
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
*/
ensure: (body: {
cwd?: string;
title?: string;
pipeline?: string;
tool?: LaneTool;
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
* folder's account, with that account's own SSH key / HTTPS login, then a
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
* SSH key was refused.
*/
clone: (body: {
url: string;
parent: string;
name?: string;
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
* @returns `{ pipelines }` — each with its `id`, display `name` and `nodes`.
*/
pipelines: () =>
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
"/lanes/pipelines"
),
/**
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
* lane and every archived feature.
* @param laneId Narrow to one lane and its archived features; omit for all.
* @returns `{ pipelines, lanes }` — see {@link PipelineStats}.
*/
pipelineStats: (laneId?: number | null) =>
request<PipelineStats>(
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
),
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
* @param repo Absolute path to an existing git repository.
* @returns `{ branches, current, worktrees }` — local branch names (not
* `origin/*`), which one is currently checked out (null if detached),
* and the repo's existing worktrees, so the picker can grey out the
* branches git would refuse to check out a second time.
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
),
/**
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
* a new lane. Returns 202: the worktree is created after the response, so
* the returned lane starts at `status: "provisioning"`.
* @param body The source repo, a slug/title, and the branch to fork from.
* @returns `{ lane }` — the lane row created immediately, before provisioning finishes.
*/
worktree: (body: {
sourceRepo: string;
slug?: string;
title?: string;
base?: string;
branch?: string;
pipeline?: string;
tool?: LaneTool;
}) =>
request<{ lane: Lane }>("/lanes/worktree", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/browse — list a directory's immediate subdirectories, for
* the Add Lane modal's folder browser. Browsers cannot expose absolute
* filesystem paths from a native picker, so this drives a server-backed one.
* @param path Absolute path to list; defaults server-side to the home dir.
* @param files List files too (`file: true`) — a file field's picker.
* @param hidden List dot entries too (never at the top of a member's home).
* @returns `{ path, parent, entries, home }` — `parent` is null at the
* root, `home` is the folder the browser's Home place opens.
*/
browse: (path?: string, files?: boolean, hidden?: boolean, host?: boolean) => {
const q = new URLSearchParams();
if (path) q.set("path", path);
if (files) q.set("files", "1");
if (hidden) q.set("hidden", "1");
if (host) q.set("host", "1");
const qs = q.toString();
return request<{
path: string;
parent: string | null;
home?: string;
/** An admin with their own Linux account (S3): the host account's
* home, opened by `host=1` instead of theirs. */
hostHome?: string;
entries: {
name: string;
path: string;
isGitRepo: boolean;
file?: boolean;
mtime?: number | null;
}[];
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
},
/**
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
* folder `name` inside `parent`. Throws `.code` `EBADNAME`, `EEXISTS`,
* `EFOLDER` (outside the caller's own folder).
*/
mkdir: (parent: string, name: string) =>
request<{ path: string }>("/lanes/browse/mkdir", {
method: "POST",
body: JSON.stringify({ parent, name }),
}),
/**
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
* browser on the server's machine), `EBADORIGIN` or `ENODIALOG` (no desktop
* / no picker program) when the caller should fall back to {@link browse}.
* @param kind `dir` or `file`.
* @param start Folder (or file) to open the picker at; home otherwise.
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
/**
* GET /api/lanes/:id — fetch one lane.
* @param id The lane id.
* @returns `{ lane }` — the {@link Lane} record.
*/
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
/**
* GET /api/lanes/:id/git — the lane's working-copy facts.
* @param id The lane id.
* @returns {@link LaneGitFacts} — `available: false` when the cwd is not a
* readable git repository, which is a normal state rather than an error.
*/
git: (id: GitTarget) => request<LaneGitFacts>(gitUrl(id, "")),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
* @param id The lane id.
* @param opts `range` picks the comparison (`work` = uncommitted vs HEAD,
* `branch` = base...HEAD). Pass `path` for one file's hunks, or
* `full: true` for every file's; with neither it is a summary.
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: GitTarget,
opts: {
range?: LaneChangeRange;
path?: string;
full?: boolean;
sha?: string;
context?: number;
} = {}
) => {
const q = new URLSearchParams();
if (opts.range) q.set("range", opts.range);
if (opts.path) q.set("path", opts.path);
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(gitUrl(id, "/changes", q));
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
gitBlame: (id: GitTarget, path: string) =>
request<LaneBlame>(gitUrl(id, "/blame", new URLSearchParams({ path }))),
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
// Another branch's history (cherry-pick's source); absent = HEAD's.
if (opts.ref) q.set("ref", opts.ref);
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
* @param id The lane id.
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/** GET /api/lanes/:id/git/download — one working file as an attachment
* (any type; a plain, non-git folder too). For an `<a download>`. */
gitDownloadUrl: (id: GitTarget, path: string) =>
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
/**
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
* ("" = the workspace root). Only creates: a taken name answers 409
* EEXISTS; at most 25 MB (413 ETOOBIG).
*/
gitUpload: (id: GitTarget, dir: string, file: File) =>
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: GitTarget, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(gitUrl(id, "/file"), {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: GitTarget, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
gitUrl(id, "/ls", new URLSearchParams({ q }))
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
* @param id The lane id.
* @param path Repo-relative directory; "" for the repo root.
*/
/**
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
*/
gitTreeAll: (id: GitTarget) =>
request<{
available: boolean;
levels: Record<string, LaneTreeEntry[]>;
truncated: boolean;
}>(gitUrl(id, "/tree-all")),
gitTree: (id: GitTarget, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(gitUrl(id, "/tree", new URLSearchParams({ path }))),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
/**
* POST /api/lanes/:id/session-worktree/remove — Claude Desktop's archive of a
* worktree session: removes the session's own worktree (never forced; git
* refuses a dirty tree) and its branch when merged (`branchKept` otherwise).
*/
removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
),
gitBranches: (id: GitTarget) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
*/
suggestCommitMessage: (id: GitTarget, paths?: string[]) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), {
method: "POST",
...(paths ? { body: JSON.stringify({ paths }) } : {}),
}),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
* `branch-delete` or `publish`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
* server refuses it without that flag; `{url}` for publish (a repo with
* no remote yet: adds `origin` and pushes HEAD with an upstream).
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: GitTarget,
action:
| "fetch"
| "pull"
| "push"
| "commit"
| "switch"
| "stage"
| "unstage"
| "discard"
| "stage-hunk"
| "discard-hunk"
| "branch-create"
| "branch-delete"
| "publish"
| "merge"
| "resolve"
| "merge-commit"
| "merge-abort"
| "cherry-pick"
| "revert"
| "undo-commit"
| "amend"
| "ignore",
body: {
/** cherry-pick / revert: the commit. */
sha?: string;
url?: string;
message?: string;
only?: "staged";
branch?: string;
base?: string;
create?: boolean;
paths?: string[];
path?: string;
hunk?: string;
/** One change block (a run of +/- lines) inside `hunk`, by index. */
block?: number;
context?: number;
confirm?: boolean;
/** resolve: which side wins, or `done` for a hand-edited file. */
side?: "ours" | "theirs" | "done";
/** fetch/pull/push after `EGITAUTH`: used for this one command, saved
* only when git accepted it and `remember` is set. */
credentials?: GitCredentialInput & { remember: boolean };
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
* ports, service liveness, and the last boot error.
*
* Its own endpoint rather than a field on the polled lane list because it
* probes ports and stats pid files. Like `/git`, a lane with no profile
* answers `available: false` with HTTP 200 — a normal state, not a fault.
* @param id The lane id.
* @returns {@link LaneRuntime}
*/
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
/**
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
* Returns 202: the boot runs in the background and streams progress as
* `lane_hook_output`, finishing with a `lane_runtime` message.
* @param id The lane id.
* @param body `build: false` reuses an existing build.
*/
up: (id: number, body: { build?: boolean } = {}) =>
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
method: "POST",
body: JSON.stringify(body),
}),
/**
* POST /api/lanes/:id/down — stop the lane's stack. Idempotent, and a no-op
* for a lane that was never brought up.
* @param id The lane id.
* @returns The pids stopped and the resulting runtime facts.
*/
down: (id: number) =>
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
method: "POST",
body: JSON.stringify({}),
}),
/**
* POST /api/lanes — create a new lane.
* @param body Optional lane initialization fields.
* @returns `{ lane }` — the created {@link Lane}.
*/
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/lanes/:id — partial update of a lane.
* @param id The lane id.
* @param patch Subset of lane fields to update.
* @returns `{ lane }` — the updated {@link Lane}.
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
* ESSHAUTH like {@link lanes.clone}). */
cloneFolder: (
id: number,
body: {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
* POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id.
* @param body Stage transition data (stage name, status, evidence, etc.).
* @returns `{ lane }` — the updated {@link Lane}.
*/
stage: (
id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) =>
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
/**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`.
*/
preflight: (id: number, action: "reset" | "remove" | "purge") =>
request<import("./types").LanePreflight>(
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
),
/**
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
* @param id The lane id.
* @param action The action name.
* @param body Optional action body (e.g. `{ confirm: true }` for remove).
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
*/
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/:id/features — list all archived features for a lane.
* @param id The lane id.
* @returns `{ features }` — all {@link LaneFeature} records.
*/
features: {
list: (id: number) =>
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
/**
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
* @param id The lane id.
* @param slug The feature slug.
* @returns `{ feature }` — the {@link LaneFeature} record.
*/
show: (id: number, slug: string) =>
request<{ feature: import("./types").LaneFeature }>(
`/lanes/${id}/features/${encodeURIComponent(slug)}`
),
},
/**
* GET /api/lanes/:id/proof — list all proof gallery entries for a lane.
* @param id The lane id.
* @returns `{ features }` — all {@link ProofFeature} records.
*/
proof: {
list: (id: number) =>
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
/**
* URL for a proof image, ready to use in an <img src>.
* @param id The lane id.
* @param slug The feature slug.
* @param group The screenshot group name.
* @param file The image filename.
* @returns A URL string pointing to the image.
*/
imageUrl: (id: number, slug: string, group: string, file: string): string =>
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
/**
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
* @param id The lane id.
* @param slug The feature slug.
* @param body Optional filters: { group?, images? }.
* @returns `{ deleted }` — count of deleted images.
*/
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
method: "DELETE",
body: JSON.stringify(body),
}),
},
mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
method: "POST",
body: "{}",
}),
profileInit: (id: number, force = false) =>
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
`/lanes/${id}/profile/init`,
{ method: "POST", body: JSON.stringify({ force }) }
),
},
// ──────────────────────────────── Locks API ────────────────────────────────
/** Named locks across all lanes: used for serialization and gating.
* Maps to `server/routes/locks.js`. */
locks: {
/**
* GET /api/locks — list all active named locks and their holders.
* @returns `{ locks }` — all {@link NamedLock} records.
*/
list: (): Promise<{ locks: NamedLock[] }> => request<{ locks: NamedLock[] }>("/locks"),
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Module-level helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Backs `api.ccConfig.backups` - a plain function (not inlined into the `api`
* object literal) purely so its query-building logic can be unit-referenced.
*
* Builds an optional `?scope=&type=` query string (each part appended only when
* present) and calls {@link request} for GET /api/cc-config/backups.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CcBackup} entries. */
function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcArtifactType }) {
const qs = new URLSearchParams();
if (params?.scope) qs.set("scope", params.scope);
if (params?.type) qs.set("type", params.type);
const q = qs.toString();
return request<{ items: CcBackup[] }>(`/cc-config/backups${q ? `?${q}` : ""}`);
}
/** Helper for GET /api/codex-config/backups with optional `scope` and `type` filters.
* Builds an optional `?scope=&type=` query string (each part appended only when
* present) and calls {@link request} for GET /api/codex-config/backups.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries. */
function requestCodexBackupsHelper(params?: {
scope?: "user" | "project";
type?: CodexArtifactType;
}) {
const qs = new URLSearchParams();
if (params?.scope) qs.set("scope", params.scope);
if (params?.type) qs.set("type", params.type);
const q = qs.toString();
return request<{ items: CodexBackup[] }>(`/codex-config/backups${q ? `?${q}` : ""}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// CC-Config types — request/response shapes for the "CC Config" explorer/editor.
// These describe on-disk Claude Code configuration artifacts (skills, agents,
// commands, output styles, memory, plugins, MCP servers, hooks, settings,
// marketplaces, keybindings, statusline) as surfaced by the /api/cc-config/*
// routes. They live in this client because they are specific to the explorer UI.
// ─────────────────────────────────────────────────────────────────────────────
/** Kind of Claude Code config artifact manageable via `api.ccConfig.write`/
* `delete` - each maps to a distinct on-disk location under `.claude/`. */
export type CcArtifactType =
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
export interface Artifact {
id: number;
kind: ArtifactKind;
title: string;
description: string;
ownerId: number | null;
owner: string | null;
mine: boolean;
canChange: boolean;
laneId: number | null;
sessionId: string | null;
/** page: the file it was published from; file: its workspace path;
* skill: its folder name; plugin: `name@marketplace`. */
source: string | null;
/** plugin: the marketplace source (owner/repo or a git URL). */
market: string | null;
/** page: the claude.ai link. */
url: string | null;
files: { path: string; size: number }[];
size: number | null;
shared: boolean;
sharedAt: string | null;
installs: number;
createdAt: string;
updatedAt: string;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
market: string;
name: string;
description: string;
plugins: {
name: string;
/** `name@marketplace`, what `claude plugin install` takes. */
key: string;
description: string;
version: string;
category: string;
}[];
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
at: string;
exists: boolean;
pinned: boolean;
}
export interface ArtifactSettings {
serverMode: boolean;
canShare: boolean;
installsEnabled: boolean;
canManage: boolean;
}
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
export interface CcWriteArgs {
// "auto-memory" targets a per-project memory file and requires `project`.
scope: "user" | "project" | "auto-memory";
type: CcArtifactType;
/** Artifact name (e.g. skill/agent/command name); omitted for singleton
* artifacts like a scope's CLAUDE.md. */
name?: string;
/** Full file contents to write. */
content: string;
/** Target project slug; required when `scope === "auto-memory"`. */
project?: string;
}
/** Body for DELETE /api/cc-config/file - remove one artifact. Mirrors the
* identifying fields of {@link CcWriteArgs} (minus `content`). */
export interface CcDeleteArgs {
scope: "user" | "project" | "auto-memory";
type: CcArtifactType;
name?: string;
project?: string;
}
/** Response shape of a successful `ccConfig.write`/`delete` call. */
export interface CcMutationResult {
ok: true;
/** Absolute path of the file that was written/deleted. */
file: string;
/** Human-readable description of what was mutated, for a toast/log line. */
target: string;
/** Path to the pre-mutation backup the server wrote, or null if none was
* needed (e.g. deleting a file that didn't exist). */
backupPath: string | null;
/** True when `write` created a new file rather than overwriting one. */
created?: boolean;
}
/** One timestamped backup of a config artifact, from GET /api/cc-config/backups -
* written automatically before every destructive `write`/`delete`. */
export interface CcBackup {
scope: "user" | "project" | "auto-memory";
type: CcArtifactType;
name: string;
/** Absolute path to the backup copy (not the original file). */
backupPath: string;
/** Whether the backed-up artifact is a directory (vs. a single file). */
isDir: boolean;
/** Backup file's mtime, epoch milliseconds. */
mtime: number;
/** Backup size in bytes; null for directory backups. */
size: number | null;
project?: string; // present for scope === "auto-memory"
}
/** Identity of one Anthropic account, as reported by the auth-profile
* endpoints. Deliberately token-free — the server never sends credentials to
* the browser, only enough to tell the accounts apart. */
export interface CcAccountIdentity {
accountUuid: string | null;
email: string | null;
displayName: string | null;
organization: string | null;
seatTier: string | null;
subscriptionType: string | null;
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
* token is stored too). */
expiresAt: number | null;
}
/** One saved auth profile from GET /api/cc-config/profiles. */
export interface CcProfile extends CcAccountIdentity {
/** Internal identifier (also its filename) — what the mutation routes take. */
name: string;
/** Display name the user edits; falls back to `name` for older profiles. */
label: string;
/** When the profile was last snapshotted, epoch ms. */
savedAt: number | null;
/** True when this profile's account is the one logged in right now. */
active: boolean;
}
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
export interface CcUsageWindow {
/** Percent of the window's allowance already spent, 0-100. */
utilization: number | null;
/** ISO timestamp the window rolls over at, when the account reports one. */
resetsAt: string | null;
}
/** An account's spent quota, for the profile switcher. Percentages and reset
* times only — the OAuth token stays on the server. */
export interface CcProfileUsage {
name: string;
/** True when the stored token had expired and was refreshed to answer this. */
refreshed: boolean;
fiveHour: CcUsageWindow | null;
sevenDay: CcUsageWindow | null;
checkedAt: string;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */
export interface AiProxyModels {
opus: string;
sonnet: string;
haiku: string;
smallFast: string;
subagent: string;
}
/** A saved git login, as the server reports it: never the password. */
export interface GitCredential {
protocol: "http" | "https";
host: string;
username: string;
savedAt: string;
hasToken: true;
}
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
export interface GitIdentity {
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: {
type?: "ed25519" | "rsa";
publicKey: string;
fingerprint: string;
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
terminal?: "on" | "off" | "foreign";
} | null;
/**
* Every key the account holds: the default ED25519 and RSA first, then the
* named ones (`name`; `id` = its file name); absent from an older server.
*/
sshKeys?: {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
* it uses, null = every key. Absent from an older server.
*/
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
/** The suggested email while none is set: `<username>@smartgift.vn`. */
defaultEmail?: string | null;
https: {
protocol: "http" | "https";
host: string;
username: string;
savedAt?: string;
hasToken: true;
}[];
}
export interface GitCredentialInput {
protocol: string;
host: string;
username: string;
password: string;
}
/** The `auth` block of a 409 `EGITAUTH`: the host git needed a login for. */
export interface GitAuthNeeded {
protocol: "http" | "https";
host: string;
username: string | null;
/** true when a login WAS sent and the host refused it. */
rejected: boolean;
}
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
/** GET/PUT /api/me/instructions — `tools`: the AI tool ids whose new panes receive them. */
export interface AgentInstructionsView {
instructions: string;
max: number;
tools: string[];
}
export interface AiTool {
id: string;
label: string;
/** The command that was found (or would be looked for) on PATH. */
command: string | null;
homepageUrl: string | null;
installed: boolean;
path: string | null;
enabled: boolean;
source: "builtin" | "remote" | "custom";
/** `claude` / `codex` keep their own run form; null for every other tool. */
launcher: "claude" | "codex" | null;
prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */
resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
proxyCapable: boolean;
/** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
/** The CLI takes a model flag (Claude, Codex, or a catalog `model` field). */
modelFlag: boolean;
/** The model a new run starts with; null inherits the CLI's own. */
defaultModel: string | null;
/** Whose priced models the tool offers (its Settings tab's pricing table). */
modelVendor: "claude" | "openai" | "google" | null;
/** How many config files its catalog file lists for the Settings tab. */
configFiles: number;
}
/** One config file a catalog tool's Settings tab may edit. */
export interface AiToolConfigFile {
index: number;
path: string;
format: "json" | "jsonc" | "text";
exists: boolean;
size: number | null;
}
export interface AiToolUpdateBody {
enabled?: boolean;
default?: true;
defaultModel?: string | null;
}
/** GET /api/ai-tools/:id/models - read from the tool's own source. */
export interface AiToolModels {
flag: boolean;
models: ModelChoice[];
defaultModel: string | null;
}
export interface AiToolsView {
tools: AiTool[];
/** The effective default tool id, or null when nothing is installed and enabled. */
default: string | null;
/** Where the catalog came from: this build, or the newer release synced daily. */
catalog?: { source: "builtin" | "remote"; tag: string | null; fetchedAt: string | null };
}
/** An install/update job (GET /api/ai-tools/job). */
export interface AiToolJob {
id: number;
/** null for update-all. */
tool: string | null;
action: "install" | "update" | "update-all";
status: "running" | "ok" | "failed";
startedAt: string;
endedAt: string | null;
exitCode: number | null;
log: string;
}
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string;
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** "shared": the host's (an admin's, in server mode), usable by everyone;
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
* only, and nobody else lists it. */
scope?: "shared" | "personal";
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean;
/** "" for `native`, which has no model of its own. */
model: string;
/** Empty for `native`. */
models: Partial<AiProxyModels>;
/** Claude model id the proxy's own model names behave as; "" when unset. */
behavesAs?: string;
savedAt?: string;
}
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
* is its HTTP code (null when nothing answered at all), and `message` is its
* own words — truncated server-side, never the key. */
export interface AiProxyProbe {
ok: boolean;
status: number | null;
message: string;
}
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
* Codex's `/v1/responses`, probed side by side. */
export interface AiProxyTestResult {
claude: AiProxyProbe;
codex: AiProxyProbe;
}
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
* key for a profile being edited — but only while `baseUrl` still matches the
* saved one, so a typed-in endpoint always needs a typed-in key. */
export interface AiProxyTestInput {
baseUrl: string;
model: string;
apiKey?: string;
name?: string;
}
/** Body accepted when creating or updating a profile. */
export interface AiProxyInput {
label: string;
baseUrl: string;
/** Omit on update to keep the stored key. */
apiKey?: string;
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
/** Server mode, on create: an admin's profile is shared unless "personal";
* a member's is always personal. Ignored in local mode. */
scope?: "shared" | "personal";
}
/** One markdown-based config artifact (skill/agent/command/output-style),
* as summarized by the `ccConfig` list endpoints. The list endpoints return
* a lightweight summary (frontmatter + a preview) rather than full contents;
* the full text is fetched on demand via {@link api.ccConfig.file}. */
export interface CcMdItem {
scope: "user" | "project";
/** Artifact name, derived from its filename/frontmatter. */
name: string;
/** Filename only, when the API returns it instead of a full path. */
file?: string;
/** Absolute path, when the API returns it instead of a bare filename. */
path?: string;
size: number;
/** File's mtime, epoch milliseconds. */
mtime: number;
/** Whether `preview` was cut short of the full file content. */
truncated: boolean;
/** Parsed YAML frontmatter key/value pairs (e.g. `description`, `model`). */
frontmatter: Record<string, string>;
/** Leading excerpt of the file body, for list-view hover/preview. */
preview: string;
}
/** One named artifact a plugin contributes — a skill, subagent, command or
* output style. `name` is the frontmatter name Claude Code addresses it by
* (`/agenthub:ship-feature`, `subagent_type: qc-local`), falling back to the
* filename when the file declares none. */
export interface CcPluginItem {
name: string;
/** Frontmatter `description`; null when the file declares none. */
description: string | null;
/** Absolute path of the backing `.md` file. */
file: string;
}
/** Counts of what a plugin contributes to Claude Code, plus its manifest
* metadata, embedded in {@link CcPlugin}. */
export interface CcPluginContributions {
skills: number;
agents: number;
commands: number;
outputStyles: number;
hooks: number;
/** The same surfaces by name, so a card can list WHICH artifacts a plugin
* ships rather than only how many. Counts above are these lists' lengths. */
items: {
skills: CcPluginItem[];
agents: CcPluginItem[];
commands: CcPluginItem[];
outputStyles: CcPluginItem[];
};
/** Parsed `plugin.json` fields; null if the plugin has no manifest. */
pluginJson: {
name?: string;
description?: string;
version?: string;
author?: { name?: string; email?: string };
homepage?: string;
repository?: string;
license?: string;
keywords?: string[];
} | null;
}
/** One installed marketplace plugin, from GET /api/cc-config/plugins - merges
* the install manifest with a live filesystem/git check. */
export interface CcPlugin {
/** Unique key within the plugin manifest (usually `<marketplace>/<name>`). */
key: string;
name: string;
/** Marketplace it was installed from; null for a manually-installed plugin. */
marketplace: string | null;
/** Install scope, e.g. "user" or "project". */
scope: string;
version: string | null;
/** Absolute path where the plugin's files live. */
installPath: string | null;
installedAt: string | null;
/** ISO timestamp of the last update check/pull for this plugin. */
lastUpdated: string | null;
/** Git commit SHA the plugin was installed/updated at, if it's a git checkout. */
gitCommitSha: string | null;
/** Whether `installPath` still exists on disk (false = broken/missing install). */
installPathExists: boolean;
/** Whether the plugin is active; null when enablement isn't tracked for it. */
enabled: boolean | null;
contributes: CcPluginContributions | null;
}
/** Response shape of GET /api/cc-config/plugins. */
export interface CcPluginsResponse {
/** Path to the plugin install manifest file. */
manifestPath: string;
manifestExists: boolean;
plugins: CcPlugin[];
}
/** One configured MCP server entry, from GET /api/cc-config/mcp. Fields are
* conditionally present depending on `kind` (stdio vs http). Note that only
* env-var/header *names* are surfaced, never their values, to avoid leaking
* secrets into the dashboard. */
export interface CcMcpServer {
name: string;
/** Which config file this entry came from (e.g. a `.mcp.json` path). */
source: string;
/**
* False when no `claude mcp` scope can address this entry, so the dashboard
* cannot remove it. Absent means removable — only the reader sets it.
*/
removable?: boolean;
/** Transport: local subprocess ("stdio"), remote HTTP, or undetermined. */
kind: "stdio" | "http" | "unknown";
/** Launch command, for `kind === "stdio"`. */
command?: string;
args?: string[];
/** Names (not values) of env vars the server config references. */
envNames?: string[];
/** Endpoint URL, for `kind === "http"`. */
url?: string;
/** Header names (not values) sent with HTTP requests. */
headers?: string[];
}
/** Response shape of GET /api/cc-config/mcp, split by config scope. */
export interface CcMcpResponse {
user: CcMcpServer[];
/** Servers configured in the current project's `.mcp.json`/settings. */
projectScoped: CcMcpServer[];
}
/** Scope the `claude mcp` CLI accepts from this form — `user` and `local`
* ONLY, matching what GET /mcp's `readMcpServers` can actually see:
* `user` is `~/.claude.json` top-level, `local` is its `projects[<root>]`
* block (the panel's `projectScoped` bucket, despite the name). The CLI's
* `project` scope writes `.mcp.json`, which `readMcpServers` never reads —
* offering it here would add a server the dashboard can never show exists. */
export type CcMcpScope = "user" | "local";
/** Body of POST /api/cc-config/mcp/add. Either `url` (http/sse) or
* `command`+`args` (stdio) is required, never both. `env` is stdio-only;
* `headers` is http/sse-only. */
export interface CcMcpAddArgs {
name: string;
scope: CcMcpScope;
transport?: "http" | "sse";
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}
/** One hook binding within a settings.json `hooks` block. */
export interface CcHookEntry {
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
matcher: string;
/** Hook kind, e.g. "command". */
type: string;
/** Shell command executed for this hook; null for non-command hook types. */
command: string | null;
/** Timeout in seconds before the hook is killed; null = no explicit timeout. */
timeout: number | null;
}
/** One settings.json layer's hook configuration, from GET /api/cc-config/hooks. */
export interface CcHookSource {
/** "project-local" is the gitignored `settings.local.json` override layer. */
scope: "user" | "project" | "project-local";
/** Absolute path to the settings file this scope reads from. */
file: string;
/** Whether the file actually exists (false = scope has no overrides yet). */
exists: boolean;
/** Hook entries keyed by event name (e.g. "PreToolUse", "Stop"). */
hooks: Record<string, CcHookEntry[]>;
}
/** Install scope understood by the `claude plugin` CLI. Note `"local"`, which
* is the same layer {@link CcSettingsSource} calls `"project-local"`. */
export type CcCliScope = "user" | "project" | "local";
/** What POST /api/cc-config/plugins/action can do to an installed plugin. */
export type CcPluginAction = "enable" | "disable" | "update" | "uninstall";
/** What POST /api/cc-config/marketplaces/action can do to a marketplace. */
export type CcMarketplaceAction = "update" | "remove";
/** Which reload slash command to send. The command strings themselves are
* fixed server-side — a pane's stdin is not a place to forward client input. */
export type CcReloadKind = "plugins" | "skills";
/** What POST /api/cc-config/reload reached. `lanes_total` counts only panes
* that were running a live `claude`; a lane sitting at a shell prompt has no
* REPL to reload and is skipped rather than reported as failed. */
export interface CcReloadResult {
commands: string[];
lanes_total: number;
lanes: {
id: string;
laneId: number | null;
/** Commands actually delivered — empty when the pane died mid-send. */
sent: string[];
error?: string;
}[];
}
/** One step of the update-all batch. `key` is stable (`marketplaces`,
* `plugin:<id>`, `reload`) so the UI can keep a row's identity across polls. */
export interface CcUpdateAllStep {
key: string;
label: string;
status: "running" | "ok" | "failed" | "skipped";
started_at?: string;
finished_at?: string;
error?: string;
note?: string;
/** Set on the AgentHub step that triggers the dashboard restart. */
restarted?: boolean;
/** Set on the reload step — the lane sessions that were typed into. */
lanes?: string[];
lanes_total?: number;
}
/**
* GET /api/cc-config/plugins/update-all - the batch worker's on-disk journal.
*
* `abandoned` is derived, not written: the worker is detached, so a host reboot
* leaves a `running` journal with nobody to finish it. `partial` means the
* batch ran to the end with at least one failed step.
*/
export interface CcUpdateAllBatch {
status: "running" | "done" | "partial" | "failed" | "abandoned";
steps: CcUpdateAllStep[];
started_at?: string;
finished_at?: string;
error?: string;
/** Set when the final `plugin-bootstrap --force` failed, i.e. the old server
* was stopped and no replacement came up. */
bootstrap_error?: string;
note?: string;
}
/** POST /api/cc-config/plugins/update-all - acknowledgement, not a result. */
export interface CcUpdateAllStarted {
ok: true;
spawned: true;
runtime_dir: string;
/** How many installed plugins the worker will walk. */
planned: number;
/** The plugin this dashboard itself runs from, updated LAST because its
* bootstrap restarts the server. Null for a plain git checkout. */
self_plugin: { marketplace: string; plugin: string } | null;
}
/** Result of a `claude plugin …` run the dashboard triggered. A non-zero CLI
* exit arrives as an HTTP 422 error instead of one of these. */
export interface CcCliResult {
ok: true;
stdout: string;
stderr: string;
}
/** One entry of `claude plugin list --available --json`'s `available` array. */
export interface CcAvailablePlugin {
/** `<name>@<marketplace>` — the exact spec to pass to `installPlugin`. */
pluginId: string;
name: string;
description?: string;
marketplaceName?: string;
source?: string;
}
/** GET /api/cc-config/plugins/available - the CLI's own plugin inventory. */
export interface CcAvailablePlugins {
installed: { id?: string; version?: string; enabled?: boolean }[];
available: CcAvailablePlugin[];
}
/** Body of PATCH /api/cc-config/settings - a per-key merge, never a whole-file
* overwrite. Keys are dotted paths (`"permissions.allow"`, `"hooks.Stop"`). */
export interface CcSettingsPatchArgs {
scope: "user" | "project" | "project-local";
set?: Record<string, unknown>;
unset?: string[];
}
/** Result of a settings key-merge. */
export interface CcSettingsPatchResult {
/** Absolute path of the settings file that was written. */
path: string;
/** Pre-write backup, or null when the file did not exist yet. */
backupPath: string | null;
created: boolean;
/** Keys actually changed - an `unset` of an absent key is not listed. */
changed: string[];
}
/** One settings.json layer's raw contents, from GET /api/cc-config/settings. */
export interface CcSettingsSource {
scope: "user" | "project" | "project-local";
file: string;
exists: boolean;
/** Parsed JSON contents; absent when `exists` is false. */
data?: unknown;
/** Raw file size in bytes, when known. */
raw_size?: number;
}
/** One memory artifact - either a project's/user's editable CLAUDE.md, or a
* read-only auto-memory file - from GET /api/cc-config/memory. */
export interface CcMemoryItem {
// "user"/"project" are the two CLAUDE.md files (editable). "auto-memory"
// is a per-project file-based memory file under ~/.claude/projects/<slug>/
// memory/ — read-only in the dashboard for now.
scope: "user" | "project" | "auto-memory";
file: string;
size: number;
mtime: number;
truncated: boolean;
preview: string;
// Present only for scope === "auto-memory":
project?: string; // the projects/<slug> dir name
name?: string; // the markdown filename (e.g. MEMORY.md, feedback_x.md)
isIndex?: boolean; // true for MEMORY.md / INDEX-*.md table-of-contents files
frontmatter?: Record<string, string>; // parsed YAML frontmatter, if any
}
/** Response shape of GET /api/cc-config/file - full contents of one config
* artifact, for the read/edit view. */
export interface CcFileResponse {
ok: true;
file: string;
/** File contents (possibly truncated - see `truncated`). */
text: string;
/** Full on-disk file size in bytes (may exceed `text.length` if truncated). */
size: number;
mtime: number;
/** Whether `text` was cut short of the full file (very large files). */
truncated: boolean;
}
/** Response shape of GET /api/cc-config/overview - counts of every config
* artifact kind, for the explorer's landing dashboard. */
export interface CcOverview {
/** Key filesystem locations the explorer reads from. */
roots: {
claudeHome: string;
projectClaudeDir: string;
projectRoot: string;
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
claudeJson: string;
};
/** Per-artifact-kind counts, split by scope where applicable. */
counts: {
skills: { user: number; project: number };
agents: { user: number; project: number };
commands: { user: number; project: number };
outputStyles: { user: number; project: number };
plugins: number;
pluginsEnabled: number;
pluginsDisabled: number;
marketplaces: number;
keybindings: number;
mcpServers: { user: number; project: number };
/** Hook-entry counts keyed by scope. */
hooks: Record<string, number>;
memory: number;
settingsFiles: number;
};
}
/** One registered plugin marketplace, from GET /api/cc-config/marketplaces. */
export interface CcMarketplace {
name: string;
/** Where the marketplace is sourced from (git repo, URL, …); null if unknown. */
source: { source?: string; repo?: string; url?: string } | null;
/** Local checkout path for a git-based marketplace; null otherwise. */
installLocation: string | null;
lastUpdated: string | null;
/** Number of plugins the marketplace publishes; null if not yet indexed. */
pluginCount: number | null;
/** Marketplace's own self-reported display name (may differ from `name`). */
marketplaceName: string | null;
marketplaceDescription: string | null;
marketplaceOwner: { name?: string; url?: string } | null;
}
/** Response shape of GET /api/cc-config/marketplaces. */
export interface CcMarketplacesResponse {
/** Path to the marketplace registry file the dashboard reads. */
knownPath: string;
knownExists: boolean;
items: CcMarketplace[];
}
/** One logical group of keybindings sharing a UI context (e.g. "editor",
* "global"), as parsed from `keybindings.json`. */
export interface CcKeybindingGroup {
context: string;
bindings: { key: string; action: string }[];
}
/** Response shape of GET /api/cc-config/keybindings. */
export interface CcKeybindings {
file: string;
exists: boolean;
/** JSON schema URL declared in the file, if any. */
schema?: string | null;
/** Doc/help URL declared in the file, if any. */
docs?: string | null;
groups: CcKeybindingGroup[];
}
/** One statusline script file, referenced by {@link CcStatusline.config}. */
export interface CcStatuslineScript {
file: string;
size: number;
mtime: number;
truncated: boolean;
preview: string;
}
/** Response shape of GET /api/cc-config/statusline. */
export interface CcStatusline {
/** Active statusline config from settings.json; null if unset. */
config: { type?: string; command?: string } | null;
/** Candidate/available statusline scripts discovered on disk. */
scripts: CcStatuslineScript[];
}
/** Response shape of GET /api/cc-config/hook-scripts - shell scripts found in
* the hooks directory that a `CcHookEntry.command` might reference. */
export interface CcHookScripts {
dir: string;
items: { name: string; file: string; size: number; mtime: number }[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Codex-Config types — request/response shapes for the "Codex Config" explorer/editor.
// These describe on-disk Codex CLI configuration artifacts (skills, agents, rules,
// memory, config, MCP servers, plugins, marketplaces) as surfaced by the
// /api/codex-config/* routes. They live in this client because they are specific
// to the explorer UI.
// ─────────────────────────────────────────────────────────────────────────────
/** Kind of Codex config artifact manageable via `api.codexConfig.write`/
* `remove` - each maps to a distinct on-disk location. */
export type CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config";
/** Scope filter accepted by most `codexConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CodexScope = "user" | "project" | "all";
/** Body for PUT /api/codex-config/file - create or overwrite one artifact. */
export interface CodexWriteArgs {
scope: "user" | "project";
type: CodexArtifactType;
/** Artifact name (e.g. skill/agent/rule name); omitted for singleton
* artifacts like memory/config. */
name?: string;
/** Full file contents to write. */
content: string;
}
/** Body for DELETE /api/codex-config/file - remove one artifact. */
export interface CodexDeleteArgs {
scope: "user" | "project";
type: CodexArtifactType;
name?: string;
}
/** Response shape of a successful `codexConfig.write`/`remove` call. */
export interface CodexMutationResult {
file: string;
created?: boolean;
backup?: { [key: string]: unknown } | null;
}
/**
* Result of creating a project's memory file (CLAUDE.md / AGENTS.md) via
* POST /api/{cc,codex}-config/project-memory. `file` is the resolved path —
* worth showing back, since the server realpaths the directory it was given.
*/
export interface ProjectMemoryResult {
file: string;
size: number;
created: true;
}
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
export interface CodexBackup {
type: string;
name: string;
file: string;
mtime: number;
size: number | null;
}
/** A discovered skill: a directory containing a SKILL.md under skills/<name>/. */
export interface CodexSkill {
scope: "user" | "project";
name: string;
path: string;
file: string;
size: number;
mtime: number;
truncated: boolean;
frontmatter: Record<string, string>;
preview: string;
}
/** A discovered agent definition (TOML file). */
export interface CodexAgent {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
meta: Record<string, unknown>;
preview: string;
}
/** A discovered rules file. */
export interface CodexRule {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
preview: string;
}
/** AGENTS.md file at either user or project scope. */
export interface CodexMemoryFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
preview?: string;
}
/** config.toml file at either user or project scope. */
export interface CodexConfigFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
content?: string;
truncated?: boolean;
}
/** Response shape of GET /api/codex-config/file - full contents of one config artifact. */
export interface CodexFileResponse {
ok: true;
file: string;
size: number;
mtime: number;
truncated: boolean;
text: string;
}
/** Response shape of GET /api/codex-config/overview. */
export interface CodexOverview {
roots: {
codexHome: string;
projectCodexDir: string;
projectRoot: string;
};
counts: {
skills: { user: number; project: number };
agents: { user: number; project: number };
rules: { user: number; project: number };
memory: { user: number; project: number };
config: { user: number; project: number };
};
}
/** One configured MCP server, from GET /api/codex-config/mcp. */
export interface CodexMcpServer {
[key: string]: unknown;
}
/** One installed plugin, from GET /api/codex-config/plugins. */
export interface CodexPlugin {
[key: string]: unknown;
}
/** One registered marketplace, from GET /api/codex-config/marketplaces. */
export interface CodexMarketplace {
[key: string]: unknown;
}
/**
* Arguments for POST /api/codex-config/mcp/add. A union, not one optional-heavy
* shape: `codex mcp add` takes either a URL or a command and rejects both or
* neither, and `env` is stdio-only. Modelling that here turns three of the
* server's `EBADARG` responses into compile errors.
*/
export type CodexMcpAddArgs =
| { name: string; url: string; bearerTokenEnvVar?: string }
| { name: string; command: string; args?: string[]; env?: Record<string, string> };
/** Arguments for POST /api/codex-config/plugins/action. */
export interface CodexPluginActionArgs {
spec: string;
action: "add" | "remove";
}
/** Arguments for POST /api/codex-config/marketplaces/action. */
export interface CodexMarketplaceActionArgs {
name: string;
action: "remove";
}
// ─────────────────────────────────────────────────────────────────────────────
// Run types — request/response shapes for the Run page's `claude` process
// spawning/management. `RunMode`/`RunStatus`/`PermissionMode`/`EffortLevel`
// mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully.
// ─────────────────────────────────────────────────────────────────────────────
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
* (`pending` / `in_progress` / `completed`). */
export interface SessionOutline {
progress: { id: string; subject: string; status: string }[];
/** Files it wrote or edited, newest first. */
outputs: string[];
/** Files it only read, newest first. */
files: string[];
/** Files attached to its prompts (the composer's drop dir), newest first. */
uploads: string[];
/** Claude Code memory files it read or wrote, newest first. */
memory: string[];
skills: string[];
mcp: string[];
}
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux
* state on every read, never cached. */
export type RunStatus = "running" | "gone";
export type CodexSandbox = "read-only" | "workspace-write" | "danger-full-access";
export type CodexApproval = "on-request" | "never";
/** Body for POST /api/run - parameters for starting a lane's terminal run. */
export interface RunStartArgs {
laneId: number;
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
resumeSessionId?: string;
effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit
* to just open the pane and let the user type. */
initialPrompt?: string;
/** Codex only: `-s`. Ignored by a claude lane. */
sandbox?: CodexSandbox;
/** Codex only: `-a`. Ignored by a claude lane. */
approval?: CodexApproval;
/** Continue the tool's last session: Codex `resume --last`, a catalog tool's `resume` args. */
resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean;
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
proxy?: string;
}
/** One session an unexpected stop left behind (GET /api/run/interrupted). */
export interface InterruptedRun {
id: string;
session_id: string;
lane_id: number;
tool: LaneTool;
cwd: string;
model: string | null;
permission_mode: PermissionMode | null;
effort: EffortLevel | null;
prompt_preview: string | null;
started_at: string;
ended_at: string | null;
lane_title: string;
lane_cwd: string;
session_name: string | null;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */
export interface RunHandle {
id: string;
laneId: number | null;
/** Which Workspace split pane's session this is: 0 is the lane's own. */
slot?: number | null;
status: RunStatus;
cwd: string | null;
model: string | null;
permissionMode: PermissionMode | null;
effort: EffortLevel | null;
resumeSessionId: string | null;
/** Claude Code session id this run created/resumed, once known. */
sessionId: string | null;
/** The pane works in a git worktree of its own (`claude --worktree`). */
worktree?: boolean;
/** ISO timestamp the tmux session was created. */
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */
promptPreview: string | null;
tool: LaneTool;
}
/** One extra folder or uploaded context file of a workspace. */
export interface LaneSource {
id: number;
kind: "folder" | "file";
path: string;
name: string;
size: number | null;
added_at: string;
/** The folder or file is gone from disk; it is skipped at spawn. */
missing: boolean;
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
supported: boolean;
maxFileBytes: number;
maxInstructions: number;
sources: LaneSource[];
}
/** One read-only memory file from GET /api/lanes/:id/memory. */
export interface LaneMemoryFile {
label: string;
path: string;
size: number;
mtime: string;
truncated: boolean;
content: string;
}
/** Response shape of GET /api/run. */
export interface RunListResponse {
items: RunHandle[];
}
/** One suggested working directory for the Run page's cwd picker. */
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
* = previously used for a run. */
kind: "dashboard" | "home" | "recent" | "repo";
path: string;
label: string;
}
/** One entry in {@link RUN_MODEL_CHOICES} - a curated model the Run page's
* model picker offers. */
export interface ModelChoice {
id: string; // value sent to claude --model
label: string; // user-facing
/** Short helper text shown under the option. */
hint?: string;
}
// Effort level choices for `claude --effort`. Higher = more thinking tokens
// before the assistant turn. Empty inherits the model's default.
export interface EffortChoice {
id: EffortLevel;
label: string;
hint?: string;
}
// Curated `--effort` options rendered by the Run page's effort picker, ordered
// from least to most reasoning budget. The empty-id entry omits the flag so the
// model's own default applies. This is UI-facing static data, not fetched.
export const RUN_EFFORT_CHOICES: EffortChoice[] = [
{ id: "", label: "Default (model decides)", hint: "No --effort flag" },
{ id: "low", label: "Low", hint: "Fast, minimal thinking" },
{ id: "medium", label: "Medium", hint: "Balanced" },
{ id: "high", label: "High", hint: "More reasoning, slower" },
{ id: "xhigh", label: "Extra-high", hint: "Deep reasoning" },
{ id: "max", label: "Max", hint: "All-out - slowest, most tokens" },
];
// Claude's fallback model list, used only when GET /api/ai-tools/claude/models
// cannot answer. "" means "inherit from settings.json" - no --model flag.
export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
// `sonnet`/`haiku` as "an alias for the latest model", and the CLI resolves
// each one itself (2.1.278 maps fable -> claude-fable-5-1). A pinned id here
// is a list that silently goes stale every release and has to be edited by
// hand; an alias follows whatever `claude` was last updated to. Cost math is
// unaffected — pricing keys off the model the transcript reports, which is
// the resolved full id.
{ id: "fable", label: "Fable (latest)", hint: "Fast output, same capability tier as Opus" },
{ id: "opus", label: "Opus (latest)", hint: "Highest capability" },
{ id: "sonnet", label: "Sonnet (latest)", hint: "Balanced capability and speed" },
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
];
/** Result of a transcript import run - returned by `api.import.rescan`,
* `scanPath`, and `upload`, and mirrored by the final `import.progress`
* WebSocket message (`phase: "complete"`). The core counters (`imported`/
* `skipped`/`errors`) are always present; the remaining fields are extra
* telemetry populated depending on which import flow produced the result. */
export interface ImportResult {
ok: boolean;
/** Which import flow produced this result. */
source: "default" | "path" | "upload";
/** Directory that was scanned; present for `source === "path"`. */
path?: string;
/** New session/event rows created. */
imported: number;
/** Entries already present in the DB, left untouched. */
skipped: number;
/** Existing rows updated with data that was missing (e.g. late token usage). */
backfilled?: number;
/** Count of files/entries that failed to parse or import. */
errors: number;
/** Distinct session ids encountered during the scan. */
sessions_seen?: number;
/** Project directories/JSONL files scanned (default/path import). */
files_scanned?: number;
/** Files actually received in the multipart request (upload import). */
files_received?: number;
/** Total transcript entries successfully parsed. */
entries_extracted?: number;
/** Entries skipped during parsing (e.g. malformed lines). */
entries_skipped?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Remote Sources types — SSH machines whose Claude Code history is pulled in.
// ─────────────────────────────────────────────────────────────────────────────
/** A configured remote source with its live sync status (server response). */
export interface RemoteSource {
/** Stable id (`src_…`), also the value written to `sessions.source`. */
id: string;
/** Human-friendly name shown in the UI and on session source badges. */
label: string;
/** SSH destination: `user@host` or a `~/.ssh/config` alias. */
host: string;
/** Optional non-default SSH port. */
ssh_port: number | null;
/** Optional path to a private key the host already controls. */
identity_file: string | null;
/** Optional remote CLAUDE_HOME (default `~/.claude`). */
remote_home: string | null;
/** Whether Codex history (`<remote_codex_home>/sessions`) is pulled too. */
sync_codex?: boolean;
/** Optional remote CODEX_HOME (default `~/.codex`). */
remote_codex_home?: string | null;
/** Whether the background poller pulls this source. */
enabled: boolean;
/** Last known sync state. */
status: "idle" | "syncing" | "ok" | "error";
/** Last error message, when `status === "error"`. */
last_error: string | null;
/** ISO timestamp of the last successful sync, or null. */
last_sync_at: string | null;
/** Import counters from the last successful sync, or null. */
last_sync_counts: {
imported?: number;
skipped?: number;
backfilled?: number;
errors?: number;
sessions_seen?: number;
sessions_tagged?: number;
/** Codex journals read / sessions written, when Codex is pulled. */
codex_files?: number;
codex_sessions?: number;
} | null;
/** Live number of sessions currently attributed to this source. */
session_count?: number;
created_at: string;
updated_at: string;
}
/** Request body for creating/updating a remote source. */
export interface RemoteSourceInput {
label: string;
host: string;
ssh_port?: number | null;
identity_file?: string | null;
remote_home?: string | null;
sync_codex?: boolean;
remote_codex_home?: string | null;
enabled?: boolean;
}
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "systemd-system" | "launchd";
enabled: boolean;
foreign: boolean;
}
/** GET /api/settings/tray. `available` false: server mode, or no graphical
* session on this Linux machine. */
export interface TrayStatus {
available: boolean;
enabled: boolean;
running: boolean;
}
/** GET /api/settings/privacy. A field the environment set is `env_owned`
* and cannot be saved from the dashboard. */
export interface PrivacySettings {
redact_secrets: boolean;
ignore_cwd: string[];
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
}
/** GET /api/settings/backup. `files` are AgentHub's own backups in `dir`,
* newest first; `last` is this server process's last run. */
export interface BackupState {
dir: string;
hour: number;
keep: number;
running: boolean;
last: {
at: string;
ok: boolean;
file?: string;
size?: number;
ms?: number;
error?: string;
} | null;
files: { name: string; size: number; mtime: string }[];
dir_error: string | null;
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
env_owned: boolean;
}
/** Result of a connectivity probe (POST /:id/test). */
export interface RemoteSourceTestResult {
ok: boolean;
message: string;
remoteProjects?: string;
}
/** Result of an on-demand sync (POST /:id/sync). */
export interface RemoteSourceSyncResult {
ok?: boolean;
imported?: number;
skipped?: number;
backfilled?: number;
errors?: number;
sessions_seen?: number;
sessions_tagged?: number;
/** Present when the sync was skipped because one was already running. */
skipped_reason?: string;
}
/** Result of POST /api/settings/import — restoring a full export bundle
* ({@link api.settings.importData}). Session-scoped tables report rows that
* were newly inserted; `sessions_skipped` counts sessions already present
* (skipped whole to stay idempotent). Config tables report new rows only. */
export interface ImportBackupResult {
ok: boolean;
/** The uploaded filename or server-side path the bundle was read from. */
source: string;
/** Bundle format marker, or null for a legacy (pre-versioning) export. */
format: string | null;
sessions_imported: number;
sessions_skipped: number;
agents: number;
events: number;
token_usage: number;
workflows: number;
dashboard_runs: number;
alert_rules: number;
model_pricing: number;
/** Bundle entries that could not be restored (e.g. a session with no id). */
errors: number;
}
/** A custom pet (`GET /api/pets`). `codex` is a Codex spritesheet, `image` one still image. */
export interface PetView {
id: string;
displayName: string;
description: string;
kind: "codex" | "image";
source: "upload" | "featured";
featuredId?: string;
by?: string;
}
export interface FeaturedPet {
id: string;
displayName: string;
by: string;
installed: boolean;
page: string;
}
export interface PetList {
pets: PetView[];
featured: FeaturedPet[];
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
}