summaryrefslogtreecommitdiffhomepage
path: root/.dispatch/transport-contract.reference.md
diff options
context:
space:
mode:
Diffstat (limited to '.dispatch/transport-contract.reference.md')
-rw-r--r--.dispatch/transport-contract.reference.md584
1 files changed, 581 insertions, 3 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 02b48a0..70d64d9 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,3 +1,100 @@
+# `@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 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]` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever
+> it changes.
+>
+> **2026-06-29 update (cancel-queued-message — ADDITIVE, 0.23.0 → 0.24.0):** a per-message
+> **cancel** for the steering message queue ships. While a turn is GENERATING and a user
+> message is queued (awaiting steering delivery), the client can cancel a single queued
+> message by id so it never runs. New `WsClientMessage` member `ChatQueueCancelMessage`
+> (`{ type: "chat.queue.cancel"; conversationId; messageId }` — fire-and-forget, idempotent;
+> success confirmed by the `message-queue` surface updating, failure as `chat.error`). New
+> HTTP path `DELETE /conversations/:id/queue/:messageId` → `QueueCancelResponse`
+> (`{ conversationId; cancelled: boolean; queue }`). The cancel is scoped per conversation;
+> cancelling a drained/already-cancelled/unknown message is a silent no-op. `@dispatch/wire`
+> is unchanged (`QueuedMessage.id` is the cancel target).
+>
+> **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).
@@ -22,24 +119,35 @@
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
import type {
AgentEvent,
+ Computer,
+ ComputerEntry,
ConversationMeta,
ConversationStatus,
+ ImageInput,
QueuedMessage,
ReasoningEffort,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
export type {
AgentEvent,
CompactionResult,
+ Computer,
+ ComputerEntry,
ConversationMeta,
ConversationStatus,
+ ImageChunk,
+ ImageInput,
QueuedMessage,
ReasoningEffort,
StepMetrics,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
/**
@@ -60,6 +168,21 @@ export interface ChatRequest {
readonly message: string;
/**
+ * 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.
@@ -80,6 +203,23 @@ export interface ChatRequest {
* 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;
}
/**
@@ -99,6 +239,14 @@ export interface ModelsResponse {
/** 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;
}
/**
@@ -170,6 +318,14 @@ 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";
@@ -221,9 +377,19 @@ export interface CwdResponse {
readonly cwd: string | null;
}
-/** Body of `PUT /conversations/:id/cwd`. */
+/**
+ * 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 ────────────────────────────────────────
@@ -248,6 +414,29 @@ 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) ──────────────────────────────────
/**
@@ -270,6 +459,78 @@ export interface CloseConversationResponse {
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) ─────────────────────────────────────────────────
/**
@@ -289,6 +550,11 @@ export interface CloseConversationResponse {
*/
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;
}
/**
@@ -305,6 +571,27 @@ export interface QueueResponse {
readonly queue: readonly QueuedMessage[];
}
+/**
+ * Response body for
+ * `DELETE /conversations/:id/queue/:messageId` — cancel (remove) a single
+ * queued steering message by id so it never runs.
+ *
+ * `cancelled` is `true` when a message with the given id was found in the
+ * conversation's queue and removed (it will never be delivered as steering nor
+ * carried into a new turn). `cancelled` is `false` when the message was not in
+ * the queue (already drained/delivered, never existed, unknown conversation)
+ * OR when the message-queue extension isn't loaded (degraded — feature off).
+ * `queue` is the post-cancel snapshot (empty when no queue extension is
+ * loaded). Idempotent — cancelling a message that is no longer queued returns
+ * `cancelled: false` with HTTP 200 (not an error), so a client may optimistically
+ * fire-and-forget a cancel and reconcile from the surface.
+ */
+export interface QueueCancelResponse {
+ readonly conversationId: string;
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
// ─── Per-conversation LSP status ──────────────────────────────────────────────
/** The connection state of a single language server for a workspace. */
@@ -324,17 +611,60 @@ export interface LspServerInfo {
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 conversation's persisted cwd, or null if unset (then `servers` is empty). */
+ /**
+ * 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
@@ -474,6 +804,29 @@ 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;
+}
+
+/**
+ * Client → server: cancel (remove) a SINGLE queued steering message by id so
+ * it never runs. The WebSocket counterpart of the HTTP
+ * `DELETE /conversations/:id/queue/:messageId` (`QueueCancelResponse`).
+ * Fire-and-forget: success is confirmed by the message-queue SURFACE updating
+ * (the cancelled message leaves the snapshot); a failure (missing/empty
+ * `conversationId` or `messageId`) arrives as a `chat.error`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered) is
+ * a silent no-op (no surface update, no error). `messageId` is the stable
+ * client-visible `QueuedMessage.id` (obtained from the queue surface snapshot
+ * or the enqueue response).
+ */
+export interface ChatQueueCancelMessage {
+ readonly type: "chat.queue.cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
}
/**
@@ -485,7 +838,8 @@ export type WsClientMessage =
| ChatSendMessage
| ChatSubscribeMessage
| ChatUnsubscribeMessage
- | ChatQueueMessage;
+ | ChatQueueMessage
+ | ChatQueueCancelMessage;
/**
* Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
@@ -509,6 +863,12 @@ export type 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;
}
/**
@@ -520,6 +880,12 @@ 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;
}
/**
@@ -606,3 +972,215 @@ export interface CompactPercentResponse {
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;
+}
+
+/**
+ * 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;
+}
+```
+