diff options
Diffstat (limited to '.dispatch/transport-contract.reference.md')
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 1114 |
1 files changed, 1062 insertions, 52 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 3a7a59c..0582d94 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,44 +1,143 @@ # `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules) > MIRRORS the backend's `@dispatch/transport-contract` package source so headless FE agents can read -> the HTTP + WebSocket wire shapes WITHOUT following the `file:` dep symlink out of this repo (which -> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — -> this file is for READING only. +> the transport types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a +> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for +> READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes. -> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract` -> (see `ui-contract.reference.md`). - -## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205) - -- `POST /chat` — body `ChatRequest` (JSON); response NDJSON stream, one `AgentEvent` per line; - resolved id also in `X-Conversation-Id` header. -- `GET /models` — `ModelsResponse`. -- `GET /conversations/:id?sinceSeq=<n>` — `ConversationHistoryResponse`: RAW, append-order, - seq-ordered slice with `seq > n` (NOT reconciled — dangling tool-calls returned as-is). - `latestSeq` = last chunk's `seq`, or the requested `sinceSeq` when caught up (empty `chunks`). -- WebSocket on :24205 — ONE path-agnostic socket multiplexes surface ops - (`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive - `WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`** - (seq lives only on `StoredChunk`, obtained via the `sinceSeq` sync after `turn-sealed`). -- DEFERRED (not built; do not depend on): `GET /conversations` (list), `POST /conversations/:id/cancel`. - -```ts +> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever +> it changes. +> +> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains +> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry` +> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is +> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced +> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when +> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` — +> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user +> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown +> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when +> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded; +> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer, +> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded. +> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream +> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3. +> +> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the +> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`), +> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`), +> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The +> `concurrency` extension tracks/limits in-flight token-generating requests per provider with +> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and +> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j. +> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface. +> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]` +> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/ +> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the +> model natively accepts images; absent → the server's vision handoff transcribes images to text before the +> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here). +> +> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image` +> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it +> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the +> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED +> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to +> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image +> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: <description>` text +> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global +> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`), +> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable +> compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j. +> +> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now +> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new +> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data +> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`) +> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in +> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the +> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j. +> +> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the +> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), +> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`), +> `SetConversationComputerRequest` + `ConversationComputerResponse` +> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` +> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/ +> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so +> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See +> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave — +> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.) +> +> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds +> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint +> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns +> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`, +> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`, +> and optional `configSource`. Also adds the previously-missing `configSource` field to +> `LspServerInfo`. See `frontend-mcp-status-handoff.md`. +> +> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds +> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and +> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and +> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation +> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`, +> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See +> `frontend-system-prompt-handoff.md`. + /** * Transport contract — the typed description of Dispatch's client–server API - * (HTTP + WebSocket). Types-only (zero runtime). Each side owns its own - * (de)serialization — the contract is the SHAPES, not the codec. + * (HTTP + WebSocket). + * + * This package is types-only (zero runtime). It is the single shared surface + * every client imports to know how to talk to the backend — the CLI, the web + * frontend (in its own repo), any third-party client — and the transport-http / + * transport-ws servers import to know what they must accept and emit. * - * The WebSocket carries BOTH chat ops (here) and surface ops (in + * Each side owns its OWN (de)serialization: there is deliberately no shared + * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, + * not the codec. The streaming response payload is the kernel's `AgentEvent` + * union, re-exported here so a client has one import for the whole wire. + * + * The WebSocket carries BOTH chat ops (defined here) and surface ops (defined in * `@dispatch/ui-contract`) over one connection; the unified `WsClientMessage` / * `WsServerMessage` unions below compose them. Chat ops are new, non-colliding - * `type` variants (`chat.*`) — the shipped surface protocol is unchanged. + * `type` variants — there is no channel wrapper, so the shipped surface protocol + * is unchanged. */ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; -import type { AgentEvent, StoredChunk } from "@dispatch/wire"; +import type { + AgentEvent, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageInput, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, +} from "@dispatch/wire"; -export type { AgentEvent, StoredChunk } from "@dispatch/wire"; +export type { + AgentEvent, + CompactionResult, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageChunk, + ImageInput, + QueuedMessage, + ReasoningEffort, + StepMetrics, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, +} from "@dispatch/wire"; /** * Request body for `POST /chat` (sent as JSON). @@ -48,49 +147,557 @@ export type { AgentEvent, StoredChunk } from "@dispatch/wire"; * response header (useful when `conversationId` was omitted). */ export interface ChatRequest { - /** The conversation to continue. Omit to start fresh — server mints an id (X-Conversation-Id). */ + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + */ readonly conversationId?: string; + /** The user's message text for this turn. */ readonly message: string; - /** Model name in `<credentialName>/<model>` form (one of `GET /models`). Omit = server default. */ + + /** + * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's + * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` + * URL. The server converts these to `image` chunks on the persisted user + * message. For a VISION-capable model (e.g. kimi), the images are passed + * through to the provider natively. For a NON-vision model (e.g. glm-5.2), + * the server's vision handoff transcribes each image to a text description + * (via a vision-capable model) and feeds that text instead — so a text-only + * model can still reason about the image's contents. Optional — omit for a + * text-only turn (backward compatible). Validation: non-array `images` → + * 400; an image without `url` → 400; empty `url` → 400. An empty array is + * accepted and treated as absent. + */ + readonly images?: readonly ImageInput[]; + + /** + * The model to use, as a model name in `<credentialName>/<model>` form — one + * of the exact strings returned by `GET /models`. Omit to use the server's + * default credential + model. + */ readonly model?: string; - /** Working directory for this turn's tool execution. Defaults server-side. Not part of the prompt. */ + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ readonly cwd?: string; + + /** + * Reasoning-effort override for THIS turn only (does not persist). When + * omitted, the server resolves the conversation's persisted value, falling + * back to `"high"`. Must be one of the `ReasoningEffort` levels; an + * unrecognized value → HTTP 400 `{ error }`. + */ + readonly reasoningEffort?: ReasoningEffort; + + /** + * The workspace to assign this conversation to. Omit for `"default"`. + * If the workspace doesn't exist yet, it is auto-created (title = id, + * defaultCwd = null). + */ + readonly workspaceId?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; } /** - * Response body for `GET /models` — the model catalog. Each entry is a model - * name in `<credentialName>/<model>` form (exactly `ChatRequest.model`). + * Response body for `GET /models` — the model catalog. + * + * Each entry in `models` is a model name in `<credentialName>/<model>` form: + * exactly the string a client passes back as `ChatRequest.model`. + * `modelInfo` is an optional map from the same `<credentialName>/<model>` key + * to model metadata (e.g. `contextWindow`). Additive — clients that only + * read `models` are unaffected. */ export interface ModelsResponse { readonly models: readonly string[]; + readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; +} + +/** Per-model metadata returned alongside the model catalog. */ +export interface ModelMetadata { + readonly contextWindow?: number; + /** + * Whether this model can natively accept image input (vision/multimodal). + * When `true`, image chunks in a user message are passed through to the + * provider. When `false`/absent, the server's vision handoff transcribes + * images to text before the model sees them. A client may use this to show + * a vision badge in the model picker. Optional — absent when unknown. + */ + readonly vision?: boolean; } /** - * Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental - * read-side history endpoint a long-lived client uses to (re)hydrate cheaply. + * Response body for + * `GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` — the + * incremental read-side history endpoint a long-lived client uses to + * (re)hydrate a conversation cheaply. All three query params are OPTIONAL and + * combine as one SELECTION + one WINDOW: + * + * - **Selection** — `sinceSeq` (exclusive lower bound, `seq > n`; omitted/0 = + * from the start) and `beforeSeq` (exclusive upper bound, `seq < s`; omitted + * = to the end). Together: `n < seq < s`. + * - **Window** — `limit=<k>` returns only the NEWEST `k` chunks of the + * selection (the response stays ASCENDING by seq). A selection with ≤ `k` + * chunks is returned whole. `limit` omitted = the full selection — exactly + * the pre-windowing behavior, so existing clients are unchanged. + * - `limit` and `beforeSeq` must be POSITIVE integers (`sinceSeq` may be 0); + * malformed, zero, or negative values → HTTP 400 `{ error }`. + * + * Intended client flows: fresh load = `?sinceSeq=0&limit=<k>` (newest window); + * tail sync = `?sinceSeq=<cursor>` (no limit); page older history in = + * `?beforeSeq=<oldestLoadedSeq>&limit=<k>`. * - * `chunks` is the RAW, append-order, seq-ordered slice with `seq > sinceSeq` - * (or the whole log when `sinceSeq` is omitted/0). NOT reconciled: a dangling - * tool-call is returned as-is. `latestSeq` is the `seq` of the LAST chunk, or — - * when the slice is empty (caught up) — the requested `sinceSeq` (0 for a full - * read of an empty conversation). After applying, the client's new cursor is - * always `latestSeq`; empty `chunks` means "nothing new past your cursor". + * Seq numbering is **1-based and gap-free** (a CONTRACTUAL GUARANTEE — see + * `StoredChunk` in `@dispatch/wire`): a client can derive "older chunks exist" + * purely from `oldestLoaded.seq > 1`; there is deliberately no + * `earliestSeq`/`hasOlder` response field. + * + * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log + * selected + windowed as above. It is NOT reconciled: a dangling tool-call is + * returned as-is (rendered as an interrupted call). Reconciliation is a + * turn-path concern — the server repairs history only when it feeds a provider, + * never on this read path — which is what preserves the per-chunk `seq` cursor + * invariant (a synthesized repair chunk would have no seq). + * + * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the + * slice is empty (the client is already caught up) — the requested `sinceSeq` + * (0 for a full read of an empty conversation). So after applying the response a + * client's new cursor is always `latestSeq`, and an empty `chunks` means + * "nothing new past your cursor". CAVEAT (windowed reads): `latestSeq` is a + * TAIL-sync cursor — on a `beforeSeq` backfill page (or any `limit`ed read that + * did not reach the log's true tail) it describes the returned window, NOT the + * conversation's high-water mark, so a client must not regress its sync cursor + * from a backfill response. (A true server-side high-water mark independent of + * the filter is deferred until a consumer needs it — it would require widening + * the store contract.) */ export interface ConversationHistoryResponse { readonly chunks: readonly StoredChunk[]; readonly latestSeq: number; } +/** + * Response body for `GET /conversations/:id/metrics` — the persisted per-turn + * (and per-step) token + timing metrics for a conversation, for a client + * reopening a past conversation to render historical usage/latency. + * + * This is a SEPARATE axis from the two other read concerns and is deliberately + * its own endpoint: the live `usage`/`step-complete`/`done` events are transient + * (not persisted), and `ConversationHistoryResponse` carries seq-cursor chunk + * CONTENT. Metrics are keyed per TURN (not per chunk) and so are not seq-filtered + * — hence a sibling route rather than a field on the history response. + * + * `turns` is every SEALED turn's `TurnMetrics` in turn order. A turn appears only + * after its metrics were persisted (post-seal); an in-flight or unsealed turn is + * absent until then. + */ +export interface ConversationMetricsResponse { + readonly turns: readonly TurnMetrics[]; +} + +export interface ConversationStatusResponse { + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; +} + +/** The aggregation window for `GET /metrics/throughput`. */ +export type ThroughputPeriod = "day" | "week" | "month"; + +/** + * One model's throughput over a period. `tokensPerSecond` is the TOKEN-WEIGHTED + * average — `Σ(output tokens) / Σ(generation seconds)` across the period's + * turns — so larger turns count proportionally more than smaller ones. + * Generation time is the model's pure decode time (it excludes tool-execution + * waits). + */ +export interface ThroughputModelStat { + /** The model name in `<credentialName>/<model>` form (as selected). */ + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + /** Total output tokens generated across the period's turns. */ + readonly totalOutputTokens: number; + /** Total pure generation time across the period's turns, in milliseconds. */ + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; +} + +/** + * Response body for + * `GET /metrics/throughput?period=day|week|month&date=<...>`. + * + * `date` is `YYYY-MM-DD` for day/week (week = the ISO Mon–Sun week containing + * that date) and `YYYY-MM` for month. Boundaries are computed in the server's + * local timezone; `start`/`end` are the resolved half-open `[start, end)` range + * in epoch-ms. `models` lists every model active in the window, sorted by + * `tokensPerSecond` descending. + */ +export interface ThroughputResponse { + readonly period: ThroughputPeriod; + readonly date: string; + /** Inclusive start of the window, epoch-ms. */ + readonly start: number; + /** Exclusive end of the window, epoch-ms. */ + readonly end: number; + readonly models: readonly ThroughputModelStat[]; +} + +// ─── Per-conversation working directory (cwd) ───────────────────────────────── + +/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ +export interface CwdResponse { + readonly conversationId: string; + readonly cwd: string | null; +} + +/** + * Body of `PUT /conversations/:id/cwd`. + * + * When `workspaceId` is provided, the conversation is assigned to that + * workspace BEFORE the cwd is persisted — so a subsequent + * `GET /conversations/:id/lsp` resolves a relative cwd against the + * workspace's `defaultCwd` (not the server default). Omit for unchanged + * workspace assignment (the conversation keeps its current workspace, or + * `"default"` if none). + */ +export interface SetCwdRequest { + readonly cwd: string; + readonly workspaceId?: string; +} + +// ─── Per-conversation reasoning effort ──────────────────────────────────────── + +/** + * Response of `GET /conversations/:id/reasoning-effort`. `reasoningEffort` is + * null when never set (the server then resolves turns at the default, + * `"high"`). + */ +export interface ReasoningEffortResponse { + readonly conversationId: string; + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * Body of `PUT /conversations/:id/reasoning-effort` — persists the + * conversation's sticky reasoning-effort level (used for every later turn that + * does not carry a per-turn `ChatRequest.reasoningEffort` override). An + * unrecognized level → HTTP 400 `{ error }`. + */ +export interface SetReasoningEffortRequest { + readonly reasoningEffort: ReasoningEffort; +} + +// ─── Per-conversation model persistence ─────────────────────────────────────── + +/** + * Response of `GET /conversations/:id/model`. `model` is the persisted model + * name in `<credentialName>/<model>` form, or null when never set (the server + * then resolves turns using the default provider + model). + */ +export interface ModelResponse { + readonly conversationId: string; + readonly model: string | null; +} + +/** + * Body of `PUT /conversations/:id/model` — persists the conversation's sticky + * model selection (used for every later turn that does not carry a per-turn + * `ChatRequest.model` override). Pass `null` to clear the persisted selection. + * An unrecognized model name is not validated here (the provider resolves it + * at turn time; an unknown model → turn error, not a 400). + */ +export interface SetModelRequest { + readonly model: string | null; +} + +// ─── Conversation close (explicit tab close) ────────────────────────────────── + +/** + * Response of `POST /conversations/:id/close` (no request body). + * + * The EXPLICIT "the user closed this conversation's tab" affordance — distinct + * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch + * the turn or the warming schedule. Closing: + * 1. aborts any in-flight turn (the kernel stops at the next event boundary, + * partial messages are persisted, and the turn SEALS normally with + * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and + * 2. stops + disables cache-warming for the conversation (persisted OFF, so a + * reopened conversation stays opt-in). + * Idempotent: closing an idle or unknown conversation succeeds with + * `abortedTurn: false`. + */ +export interface CloseConversationResponse { + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; +} + +// ─── System prompt template ─────────────────────────────────────────────────── + +/** + * Response of `GET /system-prompt` — the current global system prompt template. + * + * The template is a text string with variable placeholders (`[type:name]`) and + * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first + * turn or compaction), variables are resolved against the conversation's cwd + * and system state. The resolved system prompt is persisted per conversation + * and reused on all subsequent turns (cache-safe — no per-turn reconstruction). + */ +export interface SystemPromptTemplateResponse { + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; +} + +/** + * Body of `PUT /system-prompt` — set the global system prompt template. + * + * Changing the template does NOT affect existing conversations until they are + * compacted (the persisted resolved system prompt is stable). New + * conversations use the new template on their first turn. + */ +export interface SetSystemPromptTemplateRequest { + readonly template: string; +} + +/** + * One available variable for the system prompt template, as reported by + * `GET /system-prompt/variables` so the frontend can render the variable + * selector buttons. + */ +export interface SystemPromptVariable { + /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ + readonly type: string; + /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ + readonly name: string; + /** Human-readable description of what the variable resolves to. */ + readonly description: string; + /** + * When `true`, any name is valid for this type (e.g. `file:<path>` accepts + * any file path). The frontend should allow free-text input for the name. + */ + readonly dynamic?: boolean; +} + +/** Response of `GET /system-prompt/variables`. */ +export interface SystemPromptVariablesResponse { + readonly variables: readonly SystemPromptVariable[]; +} + +// ─── Vision settings (global) ─────────────────────────────────────────────── + +/** + * Response of `GET /settings/vision` — the global vision configuration shared + * across all conversations and vision models. + */ +export interface VisionSettingsResponse { + /** Max native images per turn (default 10); 0 disables image compaction. */ + readonly imageLimit: number; + /** Which model transcribes old images (null = auto-select a vision model). */ + readonly compactionModel: string | null; +} + +/** Body of `PUT /settings/vision` — a partial update. */ +export interface SetVisionSettingsRequest { + /** Non-negative integer (0 = disable compaction). */ + readonly imageLimit?: number; + /** A model name (`<key>/<model>`) or null (auto). */ + readonly compactionModel?: string | null; +} + +// ─── Message queue (steering) ───────────────────────────────────────────────── + +/** + * Request body for `POST /conversations/:id/queue` — enqueue a user message + * onto a conversation's message queue for mid-turn steering delivery. + * + * When a turn is ACTIVE for the conversation, the message is appended to the + * queue (the message-queue extension's per-conversation SURFACE updates) and + * delivered at the next tool-result boundary as a steering message the model + * sees alongside the tool results (a `steering` `AgentEvent` is emitted). When + * NO turn is active, enqueuing instead STARTS a new turn with the message as its + * opening prompt (equivalent to `POST /chat`) — so a fire-and-forget enqueue + * works regardless of generation state. The resolved queue + whether a turn was + * started are returned in `QueueResponse`. + * + * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. + */ +export interface QueueRequest { + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; +} + +/** + * Response body for `POST /conversations/:id/queue` — the conversation's queue + * snapshot AFTER the enqueue, so a client renders the queue from this alone. + * `conversationId` echoes the path. `startedTurn` is true when no turn was + * active and the enqueue started a new turn (the message is now the turn's + * opening prompt, not a queued steering message); the turn's events stream on + * the chat channel as usual. + */ +export interface QueueResponse { + readonly conversationId: string; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; +} + +// ─── Per-conversation LSP status ────────────────────────────────────────────── + +/** The connection state of a single language server for a workspace. */ +export type LspServerState = "connected" | "starting" | "error" | "not-started"; + +/** One language server's status as reported to the frontend. */ +export interface LspServerInfo { + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + readonly state: LspServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted + * when not yet resolved. Surfaces config-shadow debugging to the status caller + * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). + */ + readonly configSource?: string; +} + +/** Response of `GET /conversations/:id/lsp`. */ +export interface LspStatusResponse { + readonly conversationId: string; + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ + readonly cwd: string | null; + /** The language servers configured for `cwd` and their live state. */ + readonly servers: readonly LspServerInfo[]; +} + +// ─── MCP status ────────────────────────────────────────────────────── + +export type McpServerState = "connecting" | "connected" | "error" | "disconnected"; + +/** One MCP server's status as reported to the frontend. */ +export interface McpServerInfo { + /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ + readonly id: string; + /** Current connection state. */ + readonly state: McpServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source this server was resolved from. */ + readonly configSource?: string; +} + +/** Response of `GET /conversations/:id/mcp`. */ +export interface McpStatusResponse { + readonly conversationId: string; + /** + * The resolved working directory the MCP servers are configured for, or + * `null` when no cwd has been set for the conversation (then `servers` is + * empty). Mirrors the LSP status endpoint behavior. + */ + readonly cwd: string | null; + /** The MCP servers configured for `cwd` and their live state. */ + readonly servers: readonly McpServerInfo[]; +} + +/** + * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING + * request for a conversation (e.g. a frontend "warm now" button, or fast tests + * that don't want to wait for the automatic warming timer). + * + * The warm replays the conversation's existing prefix to the provider to refresh + * its prompt cache; it is NEVER persisted and NEVER streamed (no `AgentEvent`s). + * Pass the same `model`/`cwd` the conversation chats with so the warm request's + * prefix is byte-identical to a real turn (which is what makes the cache hit). + */ +export interface WarmRequest { + /** The conversation whose prompt cache to warm. */ + readonly conversationId: string; + + /** + * The model name in `<credentialName>/<model>` form the conversation uses, so + * the warm resolves the same provider + prefix. Omit to use the server default. + */ + readonly model?: string; + + /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ + readonly cwd?: string; +} + +/** + * Response body for `POST /chat/warm` (HTTP 200). The warm request's usage — + * never folded into the conversation's real usage. A client surfaces `cachePct` + * as the "last warming" cache-hit indicator. + * + * When warming cannot run because the conversation is currently generating, the + * server responds `409` with `{ error }` instead of this body. + */ +export interface WarmResponse { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** — what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** — of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still + * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite + * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's + * tiny fresh probe makes ~equal, but which on a real turn reflects new content). + */ + readonly expectedCacheRate: number; +} + // ─── WebSocket chat ops ─────────────────────────────────────────────────────── // The persistent WS connection multiplexes chat ops (below) with surface ops -// (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they -// never collide with surface ones. +// (`@dispatch/ui-contract`). The unified unions at the bottom compose both. Chat +// `type`s are namespaced (`chat.*`) so they never collide with surface ones. /** - * Client → server: start or continue a turn over the WS connection. Same fields - * as the HTTP `ChatRequest`; omit `conversationId` to start fresh — the resolved - * id arrives on the streamed `AgentEvent`s (each carries `conversationId`). + * Client → server: start or continue a turn over the WS connection. Carries the + * same fields as the HTTP `ChatRequest` (so one shape drives both transports); + * omit `conversationId` to start fresh — the resolved id arrives on the streamed + * `AgentEvent`s (each carries `conversationId`). */ export interface ChatSendMessage extends ChatRequest { readonly type: "chat.send"; @@ -98,8 +705,9 @@ export interface ChatSendMessage extends ChatRequest { /** * Server → client: one `AgentEvent` from an in-flight turn (text-delta, - * tool-call, usage, done, turn-sealed, …). Fold these into the transcript - * exactly as the HTTP NDJSON stream — same events, different carrier. + * tool-call, usage, done, turn-sealed, …). The client folds these into its + * transcript exactly as it folds the HTTP NDJSON stream — same events, different + * carrier. */ export interface ChatDeltaMessage { readonly type: "chat.delta"; @@ -117,9 +725,411 @@ export interface ChatErrorMessage { readonly message: string; } -/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */ -export type WsClientMessage = SurfaceClientMessage | ChatSendMessage; +/** + * Client → server: start WATCHING a conversation's live turn events WITHOUT + * sending a message. This is what makes a turn viewable independently of who + * started it — a second device (multi-client handoff) or a client that reloaded + * mid-turn subscribes to receive the in-flight turn. + * + * On subscribe the server replays the CURRENT in-flight turn's events so far as + * `chat.delta` messages (so a late-joiner sees the whole running turn from its + * `turn-start`), then streams subsequent live events. If no turn is in-flight, + * nothing is replayed (the client relies on `GET /conversations/:id` history). + * A client infers "generating" from a replayed `turn-start` with no matching + * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`. + * + * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs + * `chat.subscribe` for conversations it is viewing but did not send to. + */ +export interface ChatSubscribeMessage { + readonly type: "chat.subscribe"; + readonly conversationId: string; +} + +/** + * Client → server: stop watching a conversation's turn events on this connection. + * Does NOT stop or affect the turn itself (the turn runs to completion regardless + * of subscribers). The server also drops all of a connection's subscriptions when + * the socket closes — again WITHOUT aborting any in-flight turn. + */ +export interface ChatUnsubscribeMessage { + readonly type: "chat.unsubscribe"; + readonly conversationId: string; +} + +/** + * Client → server: enqueue a message onto a conversation's message queue while + * a turn is generating (steering). The WebSocket counterpart of the HTTP + * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is + * confirmed by the message-queue SURFACE updating (the FE renders the queue + * from the surface, not from a reply here); a failure (malformed/empty text, + * unknown conversation) arrives as a `chat.error`. When no turn is active, the + * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a + * client reuses this op for both "queue while generating" and "send" (the + * latter being equivalent to `chat.send`). + */ +export interface ChatQueueMessage { + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; +} + +/** + * Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat + * ops. A server discriminates on `type`. + */ +export type WsClientMessage = + | SurfaceClientMessage + | ChatSendMessage + | ChatSubscribeMessage + | ChatUnsubscribeMessage + | ChatQueueMessage; + +/** + * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat + * ops. A client discriminates on `type`. + */ +export type WsServerMessage = + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ConversationOpenMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage; + +// ─── Conversation list + metadata ──────────────────────────────────────────── + +/** + * Broadcast to all connected WS clients when a conversation is "opened" (e.g. + * via the CLI `--open` flag). The frontend decides whether to open/focus a tab + * — the backend just signals. Additive to `WsServerMessage`. + */ +export interface ConversationOpenMessage { + readonly type: "conversation.open"; + readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; +} + +/** + * Broadcast to all connected WS clients when a conversation's lifecycle status + * changes (active/idle/closed). The frontend uses this to sync tab state across + * devices in real time. + */ +export interface ConversationStatusChangedMessage { + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; +} + +/** + * Broadcast to all connected WS clients when a conversation's history has been + * compacted (summarized). The frontend should reload the conversation history + * via `GET /conversations/:id` to reflect the compacted state. + */ +export interface ConversationCompactedMessage { + readonly type: "conversation.compacted"; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; +} + +/** + * Response for `GET /conversations` — the list of all known conversations, + * sorted by `lastActivityAt` descending (most recent first). Each entry carries + * enough metadata for a conversation picker UI (id, title, timestamps). + * Optional `?q=` query param filters by id prefix (short-id resolution). + */ +export interface ConversationListResponse { + readonly conversations: readonly ConversationMeta[]; +} + +/** + * Response for `GET /conversations/:id/last` — blocks server-side until the + * in-flight turn settles (if one is active), then returns the last assistant + * text message. `content` is empty if the conversation has no assistant message. + * `turnId` is the turn that produced the message (absent if no turn ran). + */ +export interface LastMessageResponse { + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; +} + +/** + * Response for `POST /conversations/:id/open` — confirms the conversation.open + * signal was broadcast to connected WS clients. + */ +export interface OpenConversationResponse { + readonly conversationId: string; +} + +/** + * Request body for `PUT /conversations/:id/title` — set a human-readable title. + */ +export interface SetTitleRequest { + readonly title: string; +} + +/** + * Response for `GET/PUT /conversations/:id/title` — the current title. + */ +export interface TitleResponse { + readonly conversationId: string; + readonly title: string; +} + +/** + * Response for `POST /conversations/:id/compact` — confirms the conversation + * history was compacted (old messages summarized, recent messages retained). + */ +export interface CompactResponse { + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; +} + +/** + * Response for `GET /conversations/:id/compact-percent` — the token count + * at which automatic compaction triggers (0 = manual only). + */ +export interface CompactPercentResponse { + readonly conversationId: string; + readonly threshold: number; +} + +/** + * Request body for `PUT /conversations/:id/compact-percent`. + */ +export interface SetCompactPercentRequest { + readonly threshold: number; +} + +// ─── Workspaces ─────────────────────────────────────────────────────────────── + +/** + * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All + * fields are optional and only applied when the workspace is first created; + * an existing workspace is returned as-is. + */ +export interface EnsureWorkspaceRequest { + /** Display title. Default: the workspace id. Only used on create. */ + readonly title?: string; + /** Default cwd. Default: null (inherit server default). Only used on create. */ + readonly defaultCwd?: string | null; +} + +/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ +export interface WorkspaceResponse extends Workspace {} + +/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ +export interface WorkspaceListResponse { + readonly workspaces: readonly WorkspaceEntry[]; +} + +/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ +export interface SetWorkspaceTitleRequest { + readonly title: string; +} + +/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ +export interface SetWorkspaceDefaultCwdRequest { + readonly defaultCwd: string | null; +} + +/** + * Response of `DELETE /workspaces/:id`. All conversations in the workspace + * are closed (status → "closed") and reassigned to "default", then the + * workspace entity is deleted. `"default"` is non-deletable (HTTP 409). + */ +export interface DeleteWorkspaceResponse { + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; +} + +// ─── Computers (SSH handoff #2) ───────────────────────────────────────────── + +/** + * Response of `GET /computers` — every remote computer discovered from the + * system's `~/.ssh/config`, sorted by `alias`. Parallel to + * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a + * usage count). There is no Computer CRUD — to add one, the user adds a `Host` + * block to `~/.ssh/config` and Dispatch discovers it on the next read. + */ +export interface ComputerListResponse { + readonly computers: readonly ComputerEntry[]; +} + +/** + * Response of `GET /computers/:alias` — a single computer. Parallel to + * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users + * select; the remaining fields are resolved from the SSH config. + */ +export interface ComputerResponse extends Computer {} + +/** + * Response of `GET /computers/:alias/status` — the live connection state of a + * computer (whether Dispatch currently holds an open SSH session to it). Drives + * the frontend connection indicator. `error` is present only when + * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. + */ +export interface ComputerStatusResponse { + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; +} + +/** + * Body of `PUT /conversations/:id/computer` — set or clear the conversation's + * persisted computer selection (the computer analog of `SetCwdRequest`). Pass + * `null` to clear → the conversation inherits the workspace's + * `defaultComputerId`, then `null`/local. An unknown alias is not validated here + * (the connection resolves at turn time; an unreachable host → turn error, not + * a 400). Mirrors the cwd/model PUT clear semantics. + */ +export interface SetConversationComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `GET /conversations/:id/computer`. `computerId` is the persisted + * SSH `Host` alias, or `null` when never set (the conversation then inherits + * the workspace default → local). Parallel to `CwdResponse`. + */ +export interface ConversationComputerResponse { + readonly conversationId: string; + readonly computerId: string | null; +} -/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */ -export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage; +/** + * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's + * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`). + * `null` means local (no SSH). Conversations in the workspace with no + * `computerId` of their own inherit this. + */ +export interface SetWorkspaceDefaultComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `POST /computers/:alias/test` — the result of a one-shot + * connectivity probe (Dispatch opens an SSH connection to the alias, runs a + * trivial command, then closes). `ok` is true on success; `error` carries the + * failure reason (e.g. auth refused, host unreachable) when `ok` is false. + */ +export interface TestComputerResponse { + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} + +// ── Provider concurrency limits ([email protected]) ─────────────────── + +/** + * Response of `GET /concurrency/limits` — all providers with configured + * concurrency limits. Each entry pairs a provider id (e.g. "umans", + * "openai-compat") with its maximum concurrent in-flight requests. Providers + * not listed here have no limit (unlimited). + */ +export interface ConcurrencyLimitsResponse { + readonly limits: readonly { + readonly providerId: string; + readonly limit: number; + }[]; +} +/** + * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency + * limit for a provider. `limit` must be a positive integer. When a limit is + * set, requests beyond the limit queue (oldest-agent-first) rather than being + * sent immediately. + */ +export interface SetConcurrencyLimitRequest { + readonly limit: number; +} +/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ +export interface ConcurrencyLimitResponse { + readonly providerId: string; + readonly limit: number; +} +/** + * One provider's live concurrency status. + * + * - `inFlight`: how many slots are currently held (tokens being generated). + * - `queued`: how many agents are waiting for a slot. + * - `paused`: whether the queue is paused due to a 429 backoff. + * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused. + * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held + * this long before the next waiter is admitted — covers the upstream + * provider's accounting lag. Configurable + persisted per provider. + * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429 + * (adaptive headroom, one-way, persisted). The user restores the limit + * manually via `PUT /concurrency/limits/:providerId`, which clears the flag. + * When `true`, the frontend renders a visible notice/banner. + * - `autoReducedFrom`: the original limit before auto-reduction (present only + * when `autoReduced` is true). + * - `notice`: a human-readable notice string for the frontend to render as a + * banner when the limit was auto-reduced (present only when `autoReduced`). + */ +export interface ConcurrencyStatusEntry { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + readonly pausedUntil?: number; + readonly cooldownMs: number; + readonly autoReduced: boolean; + readonly autoReducedFrom?: number; + readonly notice?: string; +} +/** + * Response of `GET /concurrency/status` — live status for every provider with a + * configured limit. Providers without a limit are absent (they are unlimited). + */ +export interface ConcurrencyStatusResponse { + readonly providers: readonly ConcurrencyStatusEntry[]; +} + +// ─── Provider concurrency cooldown ──────────────────────────────────────────── + +/** + * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release + * cooldown (ms) for a provider. A recycled slot is held this long before the + * next waiter is admitted, covering the upstream provider's accounting lag. + * When no cooldown was explicitly set, the server default (350ms) is returned. + */ +export interface ConcurrencyCooldownResponse { + readonly providerId: string; + readonly cooldownMs: number; +} + +/** + * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown + * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no + * cooldown, instant re-admission). The value is persisted and applied to + * subsequently recycled slots. + */ +export interface SetConcurrencyCooldownRequest { + readonly cooldownMs: number; +} ``` + |
