Files
AgentHub/client/src/lib/api.ts
T
nntrivi2001 aff79a787c feat(lanes,git): publish a repo with no remote, and read a repo with no commits
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.

What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
  `head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
  subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
  `push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
  all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
  `validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
  local paths, file://, ext::, fd::, git://, http://, leading `-`:
  EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
  commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
  no remote offers Publish… with an inline URL box; a remote but no
  upstream relabels Push as "Publish branch". AddLaneModal disables Add
  on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
  now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
  docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
2026-09-25 11:21:30 +07:00

3922 lines
172 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.
* - `DashboardRunHistoryItem` — 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.
*
* **DashboardRunHistoryItem**
* 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,
CostResult,
DashboardEvent,
Lane,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
LaneCounts,
LaneGitFacts,
LaneRuntime,
LaneTool,
LaneWorktree,
ModelPricing,
NamedLock,
PipelineStats,
Session,
SessionDrillIn,
SessionStats,
SkillUsage,
Stats,
TranscriptListResult,
TranscriptResult,
UpdateStatusPayload,
WebhookDelivery,
WebhookProvider,
WebhookTarget,
WebhookTestResult,
WebhookType,
WorkflowData,
WorkflowRun,
WorkflowRunsResponse,
WorkflowRunDetail,
} from "./types";
import { activeSourcesParam } from "./dataScope";
// 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`.
*/
async function request<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>) || {}),
};
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(() => ({}));
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
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,
});
}
return res.json();
}
/**
* 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.
*/
export const api = {
// ───────────────────────── 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;
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?.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.
*/
get: (id: string) =>
request<{
session: Session;
agents: Agent[];
events: DashboardEvent[];
workflows: WorkflowRun[];
}>(`/sessions/${encodeURIComponent(id)}`),
/**
* 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/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; 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?.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.
*
* @returns {@link Analytics} — the chart-ready analytics payload.
*/
get: () => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
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> };
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 }>("/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 }` — success flag and the accepted path.
*/
set: (path: string) =>
request<{ ok: boolean; claude_home: string }>("/settings/claude-home", {
method: "PUT",
body: JSON.stringify({ path }),
}),
},
/**
* 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.
*
* @returns `{ ok, pricing }` — the full default rule list now in effect.
*/
resetPricing: () =>
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
method: "POST",
}),
/**
* 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.
* @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 }) =>
request<{
ok: boolean;
abandoned: number;
purged_sessions: number;
purged_events: number;
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
skillsInstall: () =>
request<{ installed: true; path: string }>("/skills/install", { method: "POST", body: "{}" }),
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.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel.
*/
get: (status?: string) =>
request<WorkflowData>(`/workflows${status && status !== "all" ? `?status=${status}` : ""}`),
/**
* 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: () => {
// 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.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
/**
* 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 }>("/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),
}),
},
// ──────────────────────────────── AI Router API ─────────────────────────────
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
* lane can be pointed at instead of the vendor's own API. Managed in the
* **AI Router** tab of Settings; attached to a lane when the lane is created.
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** 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" }
),
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
/** POST /api/ai-routers - save a new profile. */
create: (body: AiRouterInput) =>
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/ai-routers/: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<AiRouterInput>) =>
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
* before a profile that cannot reach anything is saved onto a lane.
*/
test: (body: AiRouterTestInput) =>
request<AiRouterTestResult>("/ai-routers/test", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-routers/:name - also resets every lane using it back to
* native, on disk as well as in the DB.
*/
remove: (name: string) =>
request<{ removed: string; lanes: AiRouterLaneSync }>(
`/ai-routers/${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/history - persisted run history from `dashboard_runs`. */
history: (limit = 50, options?: { laneId?: number }) => {
const qs = new URLSearchParams({ limit: String(limit) });
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane's CLI (`claude` or `codex`) was found on PATH. */
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. */
tmuxAvailable: () => request<{ available: boolean }>("/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`. */
files: (cwd: string, q?: string) => {
const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q);
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 - 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)}`),
/** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
},
// ────────────────────────────────── 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" }),
},
},
// ───────────────────────────────── 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 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),
}),
/**
* 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.
* @returns `{ path, parent, entries }` — `parent` is null at the root.
*/
browse: (path?: string) =>
request<{
path: string;
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
/**
* 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: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
/**
* 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: number,
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>(`/lanes/${id}/git/changes?${q.toString()}`);
},
/**
* 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.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
},
/**
* 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: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* 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: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/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: number, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(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: number) =>
request<{
available: boolean;
levels: Record<string, LaneTreeEntry[]>;
truncated: boolean;
}>(`/lanes/${id}/git/tree-all`),
gitTree: (id: number, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(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.
*/
gitBranches: (id: number) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
/**
* 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: number,
action:
| "fetch"
| "pull"
| "push"
| "commit"
| "switch"
| "stage"
| "unstage"
| "discard"
| "stage-hunk"
| "discard-hunk"
| "branch-create"
| "branch-delete"
| "publish"
| "merge"
| "resolve"
| "merge-commit"
| "merge-abort",
body: {
url?: string;
message?: string;
only?: "staged";
branch?: string;
base?: string;
create?: boolean;
paths?: string[];
path?: string;
hunk?: string;
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[] }>(
`/lanes/${id}/git/${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}.
*/
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),
}),
},
agentsInstall: (id: number) =>
request<{ installed: string[] }>(`/lanes/${id}/agents/install`, {
method: "POST",
body: "{}",
}),
/**
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
* or back at `native`. Writes the lane's own config on disk; only the
* profile NAME comes from here.
*/
routerApply: (id: number, profile: string) =>
request<{
tool: string;
file: string | null;
profile: string;
/** A pane is live and keeps the old models until it restarts. */
restart_required: boolean;
run_id: string | null;
}>(`/lanes/${id}/router/apply`, {
method: "POST",
body: JSON.stringify({ profile }),
}),
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 }) }
),
syncBaseCheck: (id: number, branch?: string) =>
request<{
code: number;
devDelta?: string[] | null;
overlap?: string[] | null;
collisions?: { file: string; collidesWith: string; suggestion: string }[];
}>(`/lanes/${id}/sync-base`, {
method: "POST",
body: JSON.stringify({ mode: "check", branch }),
}),
},
// ──────────────────────────────── 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";
/** 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 Router profile carries. Each maps to one env
* var Claude Code reads; a router that names its models once fills them all
* from the profile's `model`. */
export interface AiRouterModels {
opus: string;
sonnet: string;
haiku: string;
smallFast: string;
subagent: string;
}
/** One saved AI Router profile, as the API returns it — **never** with a key. */
/** 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;
}
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;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** Empty string for `native`, an http(s) URL otherwise. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean;
model: string;
models: AiRouterModels;
savedAt?: string;
}
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
* is still a successful HTTP call: `ok` is the ROUTER'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 AiRouterTestResult {
ok: boolean;
status: number | null;
message: string;
}
/** Body for POST /api/ai-routers/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 AiRouterTestInput {
baseUrl: string;
model: string;
apiKey?: string;
name?: string;
}
/** How an edit or a delete landed on the lanes already using that profile.
* Attaching copies a profile, so the store alone is not the whole truth: a
* lane listed in `failed` still carries the OLD key and endpoint. */
export interface AiRouterLaneSync {
updated: number;
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
failed: string[];
}
/** Body accepted when creating or updating a profile. */
export interface AiRouterInput {
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<AiRouterModels>;
}
/** 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). */
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;
/** Codex only: `codex resume --last` instead of picking a session id. */
resumeLast?: boolean;
}
/** 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;
/** 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;
}
/** Response shape of GET /api/run. */
export interface RunListResponse {
items: RunHandle[];
}
/**
* A row from the persistent `dashboard_runs` sqlite table - every run ever
* spawned via /api/run, including completed / errored / killed ones long
* after the in-memory handle has been reaped.
*
* Field names are snake_case here (they mirror the DB columns) whereas the live
* {@link RunHandle} uses camelCase; the `isLive` flag bridges the two by telling
* the UI whether a matching live handle still exists for this row.
*/
export interface DashboardRunHistoryItem {
id: string;
session_id: string | null;
cwd: string;
model: string | null;
permission_mode: PermissionMode | null;
effort: EffortLevel | null;
resume_session_id: string | null;
prompt_preview: string | null;
status: "running" | "killed" | "abandoned";
exit_code: number | null;
started_at: string;
ended_at: string | null;
isLive: boolean;
tool: LaneTool;
}
/** 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" },
];
// Curated model list. "" means "inherit from settings.json" - no --model flag.
// Static UI data for the Run page's model picker; the first entry inherits the
// configured default and the rest map to concrete `--model` values.
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" },
];
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
// via `codex -m`. "" inherits Codex's own configured default.
export const RUN_MODEL_CHOICES_CODEX: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your Codex config model is" },
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: "Highest capability" },
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", hint: "Balanced capability and speed" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", hint: "Fastest, lightest" },
{ id: "gpt-5.5", label: "GPT-5.5", hint: "Previous generation" },
];
/** 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 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;
} | 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;
enabled?: 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;
}