summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/transport-contract.reference.md1553
1 files changed, 1007 insertions, 546 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index e798fa7..e0ef475 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,161 +1,26 @@
-# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules)
-
-> **2026-10-20 review 3.5:** history replacements preserve monotonic seqs (retained history need not start at 1). HTTP history and WS compaction carry required `historyEpoch`; unknown cached epochs must be reloaded. Consumed by review 3.6.
->
-> 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.
->
-> **2026-10-14 delta (account-usage — ADDITIVE, `0.25.0` → `0.26.0`):** `GET /usage` + `GET /usage/:provider` +
-> `PUT /usage/:provider/settings` (Hyper daily credits) and the `AccountUsage*` types (verbatim at the end of this file).
->
-> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever
-> it changes.
->
-> **2026-09-04 delta (tiled layout plan 02 — ADDITIVE, no version bump; 2026-09-06: activation is PER WORKSPACE):** the
-> layout-provider contract ships. NEW WS message: `LayoutChangedMessage` (`{ type: "layout"; workspaceId: string; tree:
-> LayoutTree }` — server→client broadcast after every successful layout write; the FULL tree, decision 7; a global
-> fan-out and CLIENTS FILTER by their active workspace). `LayoutTree` is re-exported from `@dispatch/layout-contract`.
-> NEW HTTP routes (owned by the workspace's ACTIVE layout plugin — multiple layout plugins coexist loaded, and the
-> WORKSPACE's per-workspace enablement state (the plugins modal) picks the ONE that serves; while the workspace enables
-> NO layout plugin every route 404s, the frontend's no-provider signal): `GET /workspaces/:id/layout` (`{ tree }` —
-> read seeds a fresh workspace), `PUT /workspaces/:id/layout` (body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
-> structural-invalidity), `DELETE /workspaces/:id/layout` (re-seed on next read; returns the fresh seed). The FE
-> consumes these via `features/layout` (`adapter/http.ts` + `store.svelte.ts`); the `layout` message is parsed in
-> `adapters/ws/logic.ts` and dispatched to `store.layout.onServerTree` (non-active workspaces ignored); the workspace
-> plugins modal's toggles re-probe via `store.layout.onWorkspacePluginsChanged` (the per-workspace activation
-> trigger).
->
-> **2026-09-03 delta (plugin system — plans 05/06, NO version bump verified against the worktree):** the
-> plugin-manager + per-workspace enablement contract ships. NEW WS messages: `WorkspaceSetMessage`
-> (`{ type: "workspace.set"; workspaceId }` — client→server; the server records the connection's workspace
-> and re-pushes the workspace-filtered surface catalog on receipt + on every broadcast) and
-> `ExtensionsChangedMessage` (`{ type: "extensions-changed"; action: "load"|"unload"|"reload"|"scan"|"forget";
-> pluginId? }` — server→client broadcast after every plugin-manager mutation; clients refetch `GET /plugins`
-> while the server re-pushes the catalog). NEW HTTP routes: `GET /plugins` (`PluginListResponse` —
-> `loaded`/`disabledWithReason`/`discovered`/`missing` rows), `POST /plugins/scan` (`PluginScanResponse`),
-> `POST /plugins/:id/load|unload|reload|forget` (`PluginOpResponse` = `PluginOpSuccess | PluginOpBlocked`,
-> 404/409 with `PluginBlockReason`: `core-locked | last-provider | cascade-needs-force | not-loaded |
-> not-found | already-loaded | engine-refused`), and plan 06 §6.6's `GET /workspaces/:id/plugins`
-> (`WorkspacePluginsResponse` — read-through materialization), `PUT /workspaces/:id/plugins/:pluginId`
-> (`{ state: "enabled"|"disabled"|"unset" }` → 409 `core-locked|unloaded`), and
-> `GET /workspaces/:id/plugins/providers-status` (`{ workspaceId, enabledProviderCount }` — the §6.5
-> zero-providers guardrail signal). `GET /models?workspaceId=` is now workspace-aware (no-param = global).
-> NOTE: the body below does NOT yet contain these sections — read the types from
-> `node_modules/@dispatch/transport-contract` (or the backend worktree) until this mirror is fully
-> regenerated. The FE (plan 07) consumes them via `features/plugins`.
->
-> **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** the `/named-agents` HTTP surface +
-> conversation snapshot wiring ships. New types: `NamedAgentsResponse` (`{ namedAgents: NamedAgent[] }`,
-> `GET /named-agents`), `NamedAgentResponse` (`{ namedAgent: NamedAgent }`, `POST`/`PUT`), and
-> `DeleteNamedAgentResponse` (`{ id }`), with `CreateNamedAgentRequest` / `UpdateNamedAgentRequest`
-> (`{ name, model }`). Snapshot semantics (plan §7): `ChatRequest.namedAgent?` (persist on the
-> conversation at creation), `ModelResponse.namedAgent?` (the persisted `NamedAgentSelectionSnapshot`),
-> and `SetModelRequest.namedAgent?` (persist/clear the snapshot with the model — omit for a raw
-> selection, pass `null` notion + omit `namedAgent` to clear). The FE consumes these via
-> `features/named-agents` + the app store's CRUD/`selectModel` methods (the local `logic/types.ts`
-> re-exports `NamedAgent`/`NamedAgentSelectionSnapshot` from `@dispatch/wire` and these request/response
-> types from this package).
->
-> **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`.
+# `@dispatch/transport-contract` — in-repo reference
+Mirrored from the sibling backend on 2026-10-21 for `feature/subscription-delivery-fix`.
+Package version remains 0.26.0; local `file:` dependency unchanged.
+
+New additive messages: `chat.accepted` after opening-message storage or queue insertion,
+`chat.subscription-rejected` for a nonfatal watcher limit, and `requestId` on queue requests
+and chat errors. Correlation tokens are not idempotency keys; clients must not automatically
+retry uncertain sends. The server retains the 256 combined subscription bound.
+
+```ts
/**
* Transport contract — the typed description of Dispatch's client–server API
* (HTTP + WebSocket).
*
- * This package is types-only (zero runtime). It is the single shared surface
+ * This package defines types plus pure conversation-ID boundary validation.
+ * That deliberate runtime exception is not a shared codec. It is the 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.
*
* Each side owns its OWN (de)serialization: there is deliberately no shared
- * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES,
+ * wire 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.
*
@@ -166,39 +31,47 @@
* is unchanged.
*/
+import type { LayoutTree } from "@dispatch/layout-contract";
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
import type {
- AgentEvent,
- Computer,
- ComputerEntry,
- ConversationMeta,
- ConversationStatus,
- ImageInput,
- QueuedMessage,
- ReasoningEffort,
- StoredChunk,
- TurnMetrics,
- Workspace,
- WorkspaceEntry,
+ AgentEvent,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageInput,
+ NamedAgent,
+ NamedAgentSelectionSnapshot,
+ QueuedMessage,
+ ReasoningEffort,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
+export type { LayoutTree } from "@dispatch/layout-contract";
export type {
- AgentEvent,
- CompactionResult,
- Computer,
- ComputerEntry,
- ConversationMeta,
- ConversationStatus,
- ImageChunk,
- ImageInput,
- QueuedMessage,
- ReasoningEffort,
- StepMetrics,
- StoredChunk,
- TurnMetrics,
- Workspace,
- WorkspaceEntry,
+ AgentEvent,
+ CompactionResult,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageChunk,
+ ImageInput,
+ NamedAgent,
+ NamedAgentSelectionSnapshot,
+ QueuedMessage,
+ ReasoningEffort,
+ StepMetrics,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
+export { isValidConversationId, parseConversationId } from "./conversation-id.js";
/**
* Request body for `POST /chat` (sent as JSON).
@@ -208,68 +81,99 @@ export type {
* response header (useful when `conversationId` was omitted).
*/
export interface ChatRequest {
- /**
- * 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;
-
- /**
- * 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 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;
+ /**
+ * The conversation to continue. Omit to start a fresh conversation — the
+ * server mints an id and returns it via the `X-Conversation-Id` header.
+ * When supplied, must match `^[A-Za-z0-9_-]{1,128}$` (UUIDs and legacy IDs).
+ * Empty, null, and non-string values are rejected, not treated as absent.
+ */
+ readonly conversationId?: string;
+
+ /** The user's message text for this turn. */
+ 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).
+ */
+ 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 when
+ * omitted. Forwarded to tools for path resolution; never part of the model
+ * prompt (so it does not affect prompt caching).
+ */
+ readonly cwd?: 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;
+
+ /**
+ * 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;
+
+ /**
+ * A human-readable title for the conversation tab — persisted at creation
+ * time, after the new-conversation workspace setup resolves (so workspace
+ * assignment and first-turn system-prompt construction are not skipped) and
+ * before the first message append (so the append's auto-derived title does
+ * not overwrite it). The tab shows it instead of the default derived from
+ * the first message (`"Untitled"` until the first append). Omit to keep the
+ * auto-derived title. When present, the value is trimmed server-side; a
+ * whitespace-only value is treated as absent (auto-derive). A non-string
+ * value → HTTP 400 `{ error }`.
+ *
+ * Backward compatible — clients that omit it are unaffected. Mirrors the
+ * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the
+ * turn (no second round-trip from the client).
+ */
+ readonly title?: string;
+
+ /**
+ * Named Agent selection snapshot to persist on the conversation at creation.
+ *
+ * When the client starts a conversation via the synthetic **Named Agents**
+ * provider, it resolves the selected Named Agent to its raw `model` and sends
+ * that **plus** this snapshot so the conversation freezes how it was selected
+ * (snapshot semantics — see `NamedAgentSelectionSnapshot` in `@dispatch/wire`).
+ * The server persists it alongside the model; later edits/deletes of the Named
+ * Agent do not change this conversation's selection. Omit for a raw-model-only
+ * conversation (backward compatible). Ignored/overwritten by a subsequent
+ * `PUT /conversations/:id/model` that carries its own snapshot.
+ */
+ readonly namedAgent?: NamedAgentSelectionSnapshot;
}
/**
@@ -282,21 +186,63 @@ export interface ChatRequest {
* read `models` are unaffected.
*/
export interface ModelsResponse {
- readonly models: readonly string[];
- readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ 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;
+ 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;
+}
+
+// ─── Named Agents ────────────────────────────────────────────────────────────
+
+/**
+ * Response body for `GET /named-agents` — the list of configured Named Agents.
+ *
+ * Each record is a user-curated model preset (`id`, friendly `name`, canonical
+ * `<credential>/<model>`). This is the local configuration that drives the
+ * "Named Agents" synthetic provider in the frontend model selector and the
+ * `list_agent_models`/`summon_agent` agent tools — NOT the raw model catalog
+ * (which `GET /models` continues to serve unchanged).
+ */
+export interface NamedAgentsResponse {
+ readonly namedAgents: readonly NamedAgent[];
+}
+
+/** Response body for `POST`/`PUT /named-agents(/:id)` — the canonical saved record. */
+export interface NamedAgentResponse {
+ readonly namedAgent: NamedAgent;
+}
+
+/** Request body for `POST /named-agents` — create a Named Agent. */
+export interface CreateNamedAgentRequest {
+ /** User-chosen friendly name (trimmed; unique case-insensitively). */
+ readonly name: string;
+ /** Canonical `<credential>/<model>` identifier. */
+ readonly model: string;
+}
+
+/** Request body for `PUT /named-agents/:id` — full update of a Named Agent. */
+export interface UpdateNamedAgentRequest {
+ readonly name: string;
+ readonly model: string;
+}
+
+/**
+ * Response body for `DELETE /named-agents/:id` — confirms removal of the record
+ * with the stable id.
+ */
+export interface DeleteNamedAgentResponse {
+ readonly id: string;
}
/**
@@ -373,15 +319,15 @@ export interface ConversationHistoryResponse {
* absent until then.
*/
export interface ConversationMetricsResponse {
- readonly turns: readonly TurnMetrics[];
+ 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;
+ 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`. */
@@ -395,16 +341,16 @@ export type ThroughputPeriod = "day" | "week" | "month";
* 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;
+ /** 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;
}
/**
@@ -418,21 +364,21 @@ export interface ThroughputModelStat {
* `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[];
+ 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;
+ readonly conversationId: string;
+ readonly cwd: string | null;
}
/**
@@ -446,8 +392,8 @@ export interface CwdResponse {
* `"default"` if none).
*/
export interface SetCwdRequest {
- readonly cwd: string;
- readonly workspaceId?: string;
+ readonly cwd: string;
+ readonly workspaceId?: string;
}
// ─── Per-conversation reasoning effort ────────────────────────────────────────
@@ -458,8 +404,8 @@ export interface SetCwdRequest {
* `"high"`).
*/
export interface ReasoningEffortResponse {
- readonly conversationId: string;
- readonly reasoningEffort: ReasoningEffort | null;
+ readonly conversationId: string;
+ readonly reasoningEffort: ReasoningEffort | null;
}
/**
@@ -469,19 +415,27 @@ export interface ReasoningEffortResponse {
* unrecognized level → HTTP 400 `{ error }`.
*/
export interface SetReasoningEffortRequest {
- readonly reasoningEffort: ReasoningEffort;
+ readonly reasoningEffort: ReasoningEffort;
}
-// ─── Per-conversation model persistence ───────────────────────────────────────
+// ─── Per-conversation model ──────────────────────────────────────────────────
/**
* 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).
+ *
+ * `namedAgent` is the Named Agent selection snapshot recorded when the
+ * conversation was started/selected via a Named Agent. Present only for
+ * conversations that were created through a Named Agent (snapshot semantics: it
+ * freezes the friendly id/name + resolved model at selection time, so later
+ * edits to the Named Agent do not change this conversation's selection). A
+ * conversation holding only a raw model carries no `namedAgent`.
*/
export interface ModelResponse {
- readonly conversationId: string;
- readonly model: string | null;
+ readonly conversationId: string;
+ readonly model: string | null;
+ readonly namedAgent?: NamedAgentSelectionSnapshot;
}
/**
@@ -490,9 +444,16 @@ export interface ModelResponse {
* `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).
+ *
+ * `namedAgent` (optional) is a Named Agent selection snapshot to persist with
+ * the model — sent when the client sets this conversation's model via the
+ * synthetic **Named Agents** provider so the conversation freezes the friendly
+ * selection. When `model` is `null` (clear), the persisted snapshot is also
+ * cleared. Omit entirely for a raw-model selection (backward compatible).
*/
export interface SetModelRequest {
- readonly model: string | null;
+ readonly model: string | null;
+ readonly namedAgent?: NamedAgentSelectionSnapshot;
}
// ─── Conversation close (explicit tab close) ──────────────────────────────────
@@ -512,9 +473,9 @@ export interface SetModelRequest {
* `abortedTurn: false`.
*/
export interface CloseConversationResponse {
- readonly conversationId: string;
- /** True when an in-flight turn existed and was aborted by this close. */
- readonly abortedTurn: boolean;
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
}
// ─── System prompt template ───────────────────────────────────────────────────
@@ -529,8 +490,8 @@ export interface CloseConversationResponse {
* 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;
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
}
/**
@@ -541,7 +502,7 @@ export interface SystemPromptTemplateResponse {
* conversations use the new template on their first turn.
*/
export interface SetSystemPromptTemplateRequest {
- readonly template: string;
+ readonly template: string;
}
/**
@@ -550,43 +511,39 @@ export interface SetSystemPromptTemplateRequest {
* 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;
+ /** 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[];
+ readonly variables: readonly SystemPromptVariable[];
}
-// ─── Vision settings (global) ───────────────────────────────────────────────
+// ─── 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;
+ readonly imageLimit: number;
+ 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;
+ readonly imageLimit?: number;
+ readonly compactionModel?: string | null;
}
// ─── Message queue (steering) ─────────────────────────────────────────────────
@@ -604,15 +561,18 @@ export interface SetVisionSettingsRequest {
* 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.
+ * `text` must be a string; empty/whitespace text requires at least one image.
+ * Invalid bodies return HTTP 400 `{ error }`.
*/
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;
+ readonly images?: readonly ImageInput[];
+ readonly computerId?: 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;
}
/**
@@ -624,9 +584,9 @@ export interface QueueRequest {
* the chat channel as usual.
*/
export interface QueueResponse {
- readonly conversationId: string;
- readonly startedTurn: boolean;
- readonly queue: readonly QueuedMessage[];
+ readonly conversationId: string;
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
}
/**
@@ -645,9 +605,9 @@ export interface QueueResponse {
* fire-and-forget a cancel and reconcile from the surface.
*/
export interface QueueCancelResponse {
- readonly conversationId: string;
- readonly cancelled: boolean;
- readonly queue: readonly QueuedMessage[];
+ readonly conversationId: string;
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
}
// ─── Per-conversation LSP status ──────────────────────────────────────────────
@@ -657,39 +617,39 @@ 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;
+ /** 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[];
+ 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 ──────────────────────────────────────────────────────
@@ -698,29 +658,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte
/** 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;
+ /** 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[];
+ 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[];
}
/**
@@ -734,17 +694,17 @@ export interface McpStatusResponse {
* 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 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;
+ /**
+ * 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;
+ /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
+ readonly cwd?: string;
}
/**
@@ -756,26 +716,26 @@ export interface WarmRequest {
* 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;
+ 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 ───────────────────────────────────────────────────────
@@ -790,7 +750,9 @@ export interface WarmResponse {
* `AgentEvent`s (each carries `conversationId`).
*/
export interface ChatSendMessage extends ChatRequest {
- readonly type: "chat.send";
+ readonly type: "chat.send";
+ /** Optional client correlation token, echoed by admission replies. Not an idempotency key. */
+ readonly requestId?: string;
}
/**
@@ -800,8 +762,25 @@ export interface ChatSendMessage extends ChatRequest {
* carrier.
*/
export interface ChatDeltaMessage {
- readonly type: "chat.delta";
- readonly event: AgentEvent;
+ readonly type: "chat.delta";
+ readonly event: AgentEvent;
+}
+
+/**
+ * Replay or a single live payload exceeded the WS delivery budget. No partial
+ * replay/placeholder payload is sent; live subscription remains active. The
+ * client must refetch committed history over HTTP and reconcile provisional
+ * content by turn/step identity (not append duplicates). While this turn is
+ * active, use a bounded single-flight history refresh (step-complete may precede
+ * persistence), and a final refresh on turn-ended including aborted/failed
+ * terminals, so omitted data committed after this notice is recovered.
+ * This is not a transport failure and must not trigger reconnect/resubscribe.
+ */
+export interface ChatResyncRequiredMessage {
+ readonly type: "chat.resync-required";
+ readonly conversationId: string;
+ readonly turnId?: string;
+ readonly reason: "replay-limit" | "payload-limit";
}
/**
@@ -810,9 +789,39 @@ export interface ChatDeltaMessage {
* `TurnErrorEvent` inside a `chat.delta`.)
*/
export interface ChatErrorMessage {
- readonly type: "chat.error";
- readonly conversationId?: string;
- readonly message: string;
+ readonly type: "chat.error";
+ readonly conversationId?: string;
+ readonly requestId?: string;
+ readonly message: string;
+}
+
+/** Sent to the sender only AFTER its opening message is stored or queue insertion completes. */
+export interface ChatAcceptedMessage {
+ readonly type: "chat.accepted";
+ readonly conversationId: string;
+ readonly requestId: string;
+}
+
+/** A rejected watcher does not disconnect the socket or reject chat input. */
+export interface ChatSubscriptionRejectedMessage {
+ readonly type: "chat.subscription-rejected";
+ readonly conversationId: string;
+ readonly reason: "subscription-limit";
+}
+
+/**
+ * Accepted chat.send deferred behind compaction, sent only to its sender AFTER
+ * storage/insertion. Not a transcript event: remove the correlated optimistic
+ * opening message and display the authoritative message-queue surface instead.
+ * Live user-message/steering and history resync remain transcript-authoritative.
+ * Old clients safely ignore this additive message. Ordinary starts use chat.accepted.
+ */
+export interface ChatQueuedMessage {
+ readonly type: "chat.queued";
+ readonly conversationId: string;
+ readonly reason: "compaction";
+ readonly messageId: string;
+ readonly requestId?: string;
}
/**
@@ -826,14 +835,17 @@ export interface ChatErrorMessage {
* `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)`.
+ * `done`/`turn-sealed` yet. A bounded transport may replace the WHOLE replay
+ * with chat.resync-required; in that case its turnId identifies an active turn,
+ * and the client must recover committed content over HTTP (see that message).
+ * 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.
+ * NOTE: `chat.send` tries to auto-subscribe the sender within the shared watcher budget.
+ * Watcher rejection is nonfatal and never gates input admission.
*/
export interface ChatSubscribeMessage {
- readonly type: "chat.subscribe";
- readonly conversationId: string;
+ readonly type: "chat.subscribe";
+ readonly conversationId: string;
}
/**
@@ -843,30 +855,34 @@ export interface ChatSubscribeMessage {
* the socket closes — again WITHOUT aborting any in-flight turn.
*/
export interface ChatUnsubscribeMessage {
- readonly type: "chat.unsubscribe";
- readonly conversationId: string;
+ 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,
+ * `POST /conversations/:id/queue` (`QueueRequest`). With `requestId`, acceptance is
+ * confirmed by `chat.accepted` after queue insertion or opening-message storage.
+ * The queue surface remains authoritative for its current contents. Without a
+ * correlation token, legacy clients use surface/stream updates. 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;
+ readonly requestId?: string;
+ readonly images?: readonly ImageInput[];
+ readonly computerId?: string;
+ 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;
}
/**
@@ -882,9 +898,56 @@ export interface ChatQueueMessage {
* or the enqueue response).
*/
export interface ChatQueueCancelMessage {
- readonly type: "chat.queue.cancel";
- readonly conversationId: string;
- readonly messageId: string;
+ readonly type: "chat.queue.cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
+}
+
+/**
+ * Client → server: declare THIS connection's active workspace (plan 06
+ * §6.4). Workspace switching is instant and client-side, so the server
+ * must be told explicitly — on receipt the server records the
+ * connection's workspace and RE-PUSHES the surface catalog filtered to
+ * that workspace (surfaces contributed by plugins that are not usable in
+ * the workspace are omitted). Clients that never send it keep the
+ * unfiltered (global) catalog — backward compatible. Owned by plan 06
+ * (backend contract + server handling); plan 07 sends it from the client.
+ */
+export interface WorkspaceSetMessage {
+ readonly type: "workspace.set";
+ readonly workspaceId: string;
+}
+
+/**
+ * Broadcast to all connected WS clients after every successful layout write
+ * (plan 02 §2.5 — the `layout-chat` provider's PUT / setTree / DELETE / ops).
+ * Carries the FULL tree for ONE workspace (decision 7 — no patch protocol);
+ * the broadcast is a global fan-out and CLIENTS FILTER by their active
+ * workspace (the workspaceId is the payload, not a subscription). Additive to
+ * `WsServerMessage` — next to `extensions-changed`, a contract addition, not
+ * core layout knowledge (D-L6: no core extension or transport learns the
+ * layout domain; the transport ferries the message verbatim).
+ */
+export interface LayoutChangedMessage {
+ readonly type: "layout";
+ readonly workspaceId: string;
+ readonly tree: LayoutTree;
+}
+
+/**
+ * Broadcast to all connected WS clients after every successful home
+ * tile-layout write (the `home-tile-layout` plugin's PUT / DELETE). Carries
+ * the FULL document for ONE device orientation (`mode`: "wide" = landscape/
+ * desktop, "tall" = portrait/mobile — two independent shared layouts); the
+ * broadcast is a global fan-out and CLIENTS FILTER by their active mode.
+ * Additive to `WsServerMessage`, the same verbatim-ferry pattern as
+ * `LayoutChangedMessage` — no core extension or transport learns the tile
+ * domain.
+ */
+export interface TileLayoutChangedMessage {
+ readonly type: "tile-layout";
+ readonly mode: TileGeometryMode;
+ readonly data: TileLayoutData;
}
/**
@@ -892,48 +955,69 @@ export interface ChatQueueCancelMessage {
* ops. A server discriminates on `type`.
*/
export type WsClientMessage =
- | SurfaceClientMessage
- | ChatSendMessage
- | ChatSubscribeMessage
- | ChatUnsubscribeMessage
- | ChatQueueMessage
- | ChatQueueCancelMessage;
+ | SurfaceClientMessage
+ | ChatSendMessage
+ | ChatSubscribeMessage
+ | ChatUnsubscribeMessage
+ | ChatQueueMessage
+ | ChatQueueCancelMessage
+ | WorkspaceSetMessage;
/**
* 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;
+ | SurfaceServerMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage
+ | ConversationOpenMessage
+ | ConversationTabGroupChangedMessage
+ | ConversationStatusChangedMessage
+ | ConversationCompactedMessage
+ | ExtensionsChangedMessage
+ | LayoutChangedMessage
+ | TileLayoutChangedMessage;
// ─── Conversation list + metadata ────────────────────────────────────────────
+/** A visual grouping change, globally broadcast to every connected client. */
+export interface ConversationTabGroupChangedMessage {
+ readonly type: "conversation.tabGroupChanged";
+ readonly conversationId: string;
+ /** The conversation's persisted workspace id. */
+ readonly workspaceId: string;
+ /** The new EFFECTIVE tab group parent; null = ungrouped. */
+ readonly tabGroupParentId: string | null;
+}
+
/**
* 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;
- /**
- * The conversation's persisted per-conversation model when set (absent =
- * default provider model), so a frontend can stamp the new tab with the
- * conversation's ACTUAL model rather than the globally-selected one
- * (fix-toolcall-duplicate-keys plan Part 4).
- */
- readonly model?: string;
+ readonly type: "conversation.open";
+ /** Effective visual group parent; absent = ungrouped. */
+ readonly tabGroupParentId?: string;
+ 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;
+ /**
+ * The conversation's persisted per-conversation model when set (absent =
+ * default provider model), so a frontend can stamp the new tab with the
+ * conversation's ACTUAL model rather than the globally-selected one
+ * (fix-toolcall-duplicate-keys plan Part 4).
+ */
+ readonly model?: string;
}
/**
@@ -942,15 +1026,15 @@ export interface ConversationOpenMessage {
* 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;
+ 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;
}
/**
@@ -969,6 +1053,281 @@ export interface ConversationCompactedMessage {
readonly historyEpoch: number;
}
+// ─── Plugin manager (plan 05) ────────────────────────────────────────────────
+//
+// The wire shapes for the plugin management API (HTTP routes + the
+// `extensions-changed` broadcast). Tier/trust mirror the kernel's
+// `PluginTier`/`TrustLevel` (stable three-value enums — the manager's
+// manifest→row mapping is the drift detector: any kernel-side change fails
+// the assignment there at compile time).
+
+/** Classification tier of a plugin (mirror of the kernel's `PluginTier`). */
+export type PluginTier = "core" | "providers" | "standard";
+
+/** Trust level of a plugin (mirror of the kernel's `TrustLevel`). */
+export type PluginTrustLevel = "bundled" | "local" | "external";
+
+/** How a persisted override came to be (absent in plan-04-era records). */
+export type PluginOverrideKind = "unloaded" | "disabled";
+
+/** The persisted user override for a discovered plugin (wire shape). */
+export interface PluginOverrideInfo {
+ readonly kind?: PluginOverrideKind;
+ readonly reason?: string;
+ readonly disabledAt?: string;
+}
+
+/** A plugin currently loaded and live — the manager's `loaded` table rows. */
+export interface LoadedPluginRow {
+ readonly id: string;
+ readonly name: string;
+ readonly version: string;
+ readonly tier: PluginTier;
+ readonly trust: PluginTrustLevel;
+ /** True for tier "core" — the server refuses to unload it. */
+ readonly locked: boolean;
+ readonly dependsOn: readonly string[];
+ /**
+ * The plugin's frontend section (plan 03), present when the loaded plugin
+ * declares one AND its entry file is servable (resolved inside the plugin
+ * directory). `apiVersion`/`entry` mirror the manifest; `bust` is the entry
+ * file's mtimeMs — the SAME value the backend loader uses as its `?v=`
+ * cache-bust query — so clients can build content-versioned import URLs.
+ */
+ readonly frontend?: LoadedPluginFrontend;
+}
+
+/** The wire shape of a servable frontend section (plan 03). */
+export interface LoadedPluginFrontend {
+ readonly apiVersion: string;
+ readonly entry: string;
+ readonly bust: string;
+}
+
+/** A plugin the system refused or failed to load — disabled-with-reason. */
+export interface DisabledWithReasonRow {
+ readonly id: string;
+ readonly name: string;
+ readonly version: string;
+ readonly tier: PluginTier;
+ readonly trust: PluginTrustLevel;
+ readonly reason: string;
+}
+
+/** A plugin present on disk but not loaded — the manager's second table. */
+export interface DiscoveredPluginRow {
+ readonly id: string;
+ readonly name: string;
+ readonly version: string;
+ readonly tier: PluginTier;
+ readonly trust: PluginTrustLevel;
+ /** Absolute plugin directory. */
+ readonly dir: string;
+ /**
+ * The persisted user override, if any. Absent = a fresh discovery the
+ * consent gate renders UNCHECKED; present rows distinguish a deliberate
+ * unload (`kind: "unloaded"`) from an explicit disable (`"disabled"`).
+ */
+ readonly override?: PluginOverrideInfo;
+}
+
+/** An override record whose plugin directory is gone — a greyed-out row. */
+export interface MissingPluginRow {
+ readonly id: string;
+ readonly reason?: string;
+ readonly disabledAt?: string;
+}
+
+/** Response for `GET /plugins` — the two manager tables + the missing strip. */
+export interface PluginListResponse {
+ readonly loaded: readonly LoadedPluginRow[];
+ readonly disabledWithReason: readonly DisabledWithReasonRow[];
+ readonly discovered: readonly DiscoveredPluginRow[];
+ readonly missing: readonly MissingPluginRow[];
+}
+
+/** One directory that failed scan validation (manifest missing/unparsable/invalid). */
+export interface InvalidPluginDir {
+ readonly dir: string;
+ readonly errors: readonly string[];
+}
+
+/** One directory whose (valid) manifest is incompatible with the kernel. */
+export interface IncompatiblePluginDir {
+ readonly dir: string;
+ readonly id: string;
+ readonly reason: string;
+}
+
+/**
+ * Response for `POST /plugins/scan` — the manual scan's product (§5): newly
+ * discovered plugins are UNCHECKED (the consent gate — the scan never
+ * auto-loads), broken directories report their validation errors.
+ */
+export interface PluginScanResponse {
+ /** The plugins root that was scanned. */
+ readonly root: string;
+ /** Fresh discoveries: loadable, not loaded, no override. */
+ readonly newPlugins: readonly DiscoveredPluginRow[];
+ /** Directories that failed manifest/entry validation. */
+ readonly invalid: readonly InvalidPluginDir[];
+ /** Valid manifests incompatible with the running kernel. */
+ readonly incompatible: readonly IncompatiblePluginDir[];
+}
+
+/** The mutation a plugin-management route performed. */
+export type PluginAction = "load" | "unload" | "reload" | "forget";
+
+/** Why a plugin operation was refused (HTTP 409/404 — see the route docs). */
+export type PluginBlockReason =
+ /** Core plugins cannot be unloaded (never force-able). */
+ | "core-locked"
+ /** Unloading would leave zero loaded providers (never force-able). */
+ | "last-provider"
+ /** Loaded dependents exist — retry with `force` (the dependents are listed). */
+ | "cascade-needs-force"
+ /** The target plugin is not currently loaded. */
+ | "not-loaded"
+ /** No such plugin was found (no loadable directory with that id). */
+ | "not-found"
+ /** The target is already loaded (load; use reload). */
+ | "already-loaded"
+ /** The engine refused or failed the operation (reason in `message`). */
+ | "engine-refused";
+
+/** Successful plugin-management operation (HTTP 200). */
+export interface PluginOpSuccess {
+ readonly ok: true;
+ readonly action: PluginAction;
+ readonly id: string;
+ /** Forced unload: the dependents that were unloaded before the target. */
+ readonly cascade?: readonly string[];
+ /** Forced reload: dependents unloaded around the target (engine report). */
+ readonly unloadedDependents?: readonly string[];
+ /** Forced reload: dependents reloaded after the target (engine report). */
+ readonly reloadedDependents?: readonly string[];
+ /** Whether a saved override record was removed (load / reload / forget). */
+ readonly clearedOverride?: boolean;
+ /** Forced ops stop ALL agents first — how many in-flight turns aborted. */
+ readonly stoppedAgents?: number;
+}
+
+/** Refused plugin-management operation (HTTP 404 / 409). */
+export interface PluginOpBlocked {
+ readonly ok: false;
+ readonly action: PluginAction;
+ readonly id: string;
+ readonly reason: PluginBlockReason;
+ /** Human-readable reason (server-log grammar) for the UI to surface. */
+ readonly message: string;
+ /** For `cascade-needs-force`: the loaded dependents a force would remove. */
+ readonly dependents?: readonly string[];
+}
+
+/** Response for the plugin-management mutation routes. */
+export type PluginOpResponse = PluginOpSuccess | PluginOpBlocked;
+
+/**
+ * Broadcast to ALL connected WS clients after every plugin-manager mutation
+ * (load / unload / reload / scan / forget) so every connected frontend
+ * refetches the extension list AND the surface catalog (§11 multi-client
+ * coherence). The payload is intentionally minimal — the client refreshes
+ * from `GET /plugins` + the surface catalog rather than diffing here.
+ */
+export interface ExtensionsChangedMessage {
+ readonly type: "extensions-changed";
+ readonly action: "load" | "unload" | "reload" | "scan" | "forget";
+ /** The plugin the action applied to (absent for a scan). */
+ readonly pluginId?: string;
+}
+
+// ─── Per-workspace plugin enablement (plan 06 §6.6) ─────────────────────────
+//
+// The workspace modal's read+write contract for "is this plugin used HERE".
+// The routes are thin shells over the workspace-plugin-config core service;
+// the state-transition + validation logic lives THERE (pure, unit-tested).
+
+/**
+ * The wire form of a per-workspace plugin state: `"enabled"` / `"disabled"`
+ * — an explicit record — or the string `"undefined"` for NO record (the
+ * blank slate, §7). The triple is spelled out on the wire (rather than
+ * omitting the field) so a client can render all three states uniformly.
+ */
+export type WorkspacePluginStateValue = "enabled" | "disabled" | "undefined";
+
+/**
+ * One row of `GET /workspaces/:id/plugins` — a plugin's effective state in
+ * the workspace plus the metadata the modal needs (unloaded → greyed;
+ * core → locked).
+ */
+export interface WorkspacePluginRow {
+ readonly pluginId: string;
+ readonly tier: PluginTier;
+ readonly state: WorkspacePluginStateValue;
+ /** Whether the plugin is currently loaded globally (plan 05). */
+ readonly loaded: boolean;
+ /** True for tier "core" — the per-workspace state cannot be changed. */
+ readonly locked: boolean;
+}
+
+/**
+ * Response for `GET /workspaces/:id/plugins` — this workspace's effective
+ * plugin states. READING triggers read-through materialization (§6.1):
+ * the first read snapshots inherited records from the default workspace
+ * (Core → `enabled` always). `enabledProviderCount` is folded in for
+ * convenience; the dedicated `providers-status` route reports the same
+ * signal without parsing the full list.
+ */
+export interface WorkspacePluginsResponse {
+ readonly workspaceId: string;
+ readonly plugins: readonly WorkspacePluginRow[];
+ /** Loaded provider-tier plugins effectively enabled here (§6.5 guardrail). */
+ readonly enabledProviderCount: number;
+}
+
+/**
+ * Request body for `PUT /workspaces/:id/plugins/:pluginId` — set/clear
+ * this workspace's state for one plugin. `"unset"` returns it to the
+ * blank slate (no record). The server refuses (`core-locked` /
+ * `unloaded`, HTTP 409) changes for CORE plugins and for plugins that are
+ * not currently loaded — strict layering: only the global plugin-manager
+ * loads code (§7).
+ */
+export interface SetWorkspacePluginStateRequest {
+ readonly state: "enabled" | "disabled" | "unset";
+}
+
+/** Response for `PUT /workspaces/:id/plugins/:pluginId` (HTTP 200). */
+export interface SetWorkspacePluginStateResponse {
+ readonly workspaceId: string;
+ readonly pluginId: string;
+ /** The resulting effective state (`"undefined"` after `"unset"`). */
+ readonly state: WorkspacePluginStateValue;
+}
+
+/** Why a `PUT /workspaces/:id/plugins/:pluginId` was refused (HTTP 409). */
+export type SetWorkspacePluginStateBlockReason = "core-locked" | "unloaded";
+
+/** Refused per-workspace state change (HTTP 409). */
+export interface SetWorkspacePluginStateBlocked {
+ readonly workspaceId: string;
+ readonly pluginId: string;
+ readonly reason: SetWorkspacePluginStateBlockReason;
+ /** Human-readable reason (server-log grammar) for the UI to surface. */
+ readonly message: string;
+}
+
+/** Response for `GET /workspaces/:id/plugins/providers-status` (§6.5). */
+export interface WorkspacePluginProvidersStatusResponse {
+ readonly workspaceId: string;
+ /**
+ * Loaded provider-tier plugins effectively `enabled` in this workspace.
+ * `0` is the first-boot guardrail signal (§7): show a visible "no
+ * providers enabled" prompt instead of a silently empty model picker.
+ */
+ readonly enabledProviderCount: number;
+}
+
/**
* Response for `GET /conversations` — the list of all known conversations,
* sorted by `lastActivityAt` descending (most recent first). Each entry carries
@@ -976,7 +1335,7 @@ export interface ConversationCompactedMessage {
* Optional `?q=` query param filters by id prefix (short-id resolution).
*/
export interface ConversationListResponse {
- readonly conversations: readonly ConversationMeta[];
+ readonly conversations: readonly ConversationMeta[];
}
/**
@@ -986,9 +1345,9 @@ export interface ConversationListResponse {
* `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;
+ readonly conversationId: string;
+ readonly content: string;
+ readonly turnId?: string;
}
/**
@@ -996,22 +1355,33 @@ export interface LastMessageResponse {
* signal was broadcast to connected WS clients.
*/
export interface OpenConversationResponse {
- readonly conversationId: string;
+ readonly conversationId: string;
+}
+
+/** Body for `PUT /conversations/:id/tab-group`. null = remove from its group (ungroup). */
+export interface SetTabGroupRequest {
+ readonly parentId: string | null;
+}
+
+/** Response for `PUT /conversations/:id/tab-group` — the new EFFECTIVE value. */
+export interface TabGroupResponse {
+ readonly conversationId: string;
+ readonly tabGroupParentId: string | null;
}
/**
* Request body for `PUT /conversations/:id/title` — set a human-readable title.
*/
export interface SetTitleRequest {
- readonly title: string;
+ readonly title: string;
}
/**
* Response for `GET/PUT /conversations/:id/title` — the current title.
*/
export interface TitleResponse {
- readonly conversationId: string;
- readonly title: string;
+ readonly conversationId: string;
+ readonly title: string;
}
/**
@@ -1019,10 +1389,10 @@ export interface TitleResponse {
* history was compacted (old messages summarized, recent messages retained).
*/
export interface CompactResponse {
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
/**
@@ -1030,15 +1400,15 @@ export interface CompactResponse {
* at which automatic compaction triggers (0 = manual only).
*/
export interface CompactPercentResponse {
- readonly conversationId: string;
- readonly threshold: number;
+ readonly conversationId: string;
+ readonly threshold: number;
}
/**
* Request body for `PUT /conversations/:id/compact-percent`.
*/
export interface SetCompactPercentRequest {
- readonly threshold: number;
+ readonly threshold: number;
}
// ─── Workspaces ───────────────────────────────────────────────────────────────
@@ -1049,28 +1419,33 @@ export interface SetCompactPercentRequest {
* 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;
+ /** 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. */
+/**
+ * Response of `GET /workspaces` — the workspaces sorted by `lastActivityAt`
+ * desc. The optional `?archived=` query selects which workspaces are included:
+ * `"true"` → archived only, `"all"` → every workspace; absent (or any other
+ * value) → non-archived only (the home grid's default view).
+ */
export interface WorkspaceListResponse {
- readonly workspaces: readonly WorkspaceEntry[];
+ readonly workspaces: readonly WorkspaceEntry[];
}
/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
export interface SetWorkspaceTitleRequest {
- readonly title: string;
+ readonly title: string;
}
/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
export interface SetWorkspaceDefaultCwdRequest {
- readonly defaultCwd: string | null;
+ readonly defaultCwd: string | null;
}
/**
@@ -1079,12 +1454,12 @@ export interface SetWorkspaceDefaultCwdRequest {
* 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;
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
}
-// ─── Computers (SSH handoff #2) ─────────────────────────────────────────────
+// ─── Computers ───────────────────────────────────────────────────────────────
/**
* Response of `GET /computers` — every remote computer discovered from the
@@ -1094,7 +1469,7 @@ export interface DeleteWorkspaceResponse {
* block to `~/.ssh/config` and Dispatch discovers it on the next read.
*/
export interface ComputerListResponse {
- readonly computers: readonly ComputerEntry[];
+ readonly computers: readonly ComputerEntry[];
}
/**
@@ -1111,10 +1486,10 @@ export interface ComputerResponse extends Computer {}
* `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;
+ readonly alias: string;
+ readonly state: "disconnected" | "connecting" | "connected" | "error";
+ readonly error?: string;
+ readonly knownHost: boolean;
}
/**
@@ -1126,7 +1501,7 @@ export interface ComputerStatusResponse {
* a 400). Mirrors the cwd/model PUT clear semantics.
*/
export interface SetConversationComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -1135,8 +1510,8 @@ export interface SetConversationComputerRequest {
* the workspace default → local). Parallel to `CwdResponse`.
*/
export interface ConversationComputerResponse {
- readonly conversationId: string;
- readonly computerId: string | null;
+ readonly conversationId: string;
+ readonly computerId: string | null;
}
/**
@@ -1146,7 +1521,7 @@ export interface ConversationComputerResponse {
* `computerId` of their own inherit this.
*/
export interface SetWorkspaceDefaultComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -1156,25 +1531,129 @@ export interface SetWorkspaceDefaultComputerRequest {
* failure reason (e.g. auth refused, host unreachable) when `ok` is false.
*/
export interface TestComputerResponse {
- readonly alias: string;
- readonly ok: boolean;
- readonly error?: string;
+ readonly alias: string;
+ readonly ok: boolean;
+ readonly error?: string;
}
-// ── Provider concurrency limits ([email protected]) ───────────────────
+// ─── Heartbeat ───────────────────────────────────────────────────────────────
+
+/**
+ * The per-workspace Heartbeat config — a single record stored per workspace.
+ *
+ * A heartbeat is an AI that runs on a configurable loop per workspace: when
+ * `enabled`, the backend summons a NEW conversation every `intervalMinutes`
+ * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the
+ * opening user message, and inherits the workspace's computer (SSH) + cwd +
+ * standard tool kit. The main purpose is monitoring (e.g. checking if chats
+ * are stuck).
+ *
+ * `model` is a model name in `<credentialName>/<model>` form (one of the strings
+ * from `GET /models`), or the empty string to use the server default.
+ * `reasoningEffort` is `null` to inherit the workspace default, or an explicit
+ * level. The scheduler resets its timer after each run completes (not a fixed
+ * wall-clock schedule); on backend restart it resumes scheduling for enabled
+ * heartbeats.
+ *
+ * `inactiveOnly` (default `true`) gates each fire on the configured workspace
+ * having NO active agents (no conversation with status `"active"` or `"queued"`):
+ * the heartbeat stays quiet while the user is actively working, and only fires
+ * when the workspace is idle. Set `false` to fire unconditionally.
+ */
+export interface HeartbeatConfig {
+ /** Whether the heartbeat loop is active for this workspace. */
+ readonly enabled: boolean;
+ /**
+ * When `true` (the default), the heartbeat SKIPS a fire when the configured
+ * workspace has any active agents — conversations whose persisted status is
+ * `"active"` (driving a turn) or `"queued"` (waiting on the message queue).
+ * The fire is silently skipped (no run is recorded); the scheduler re-arms
+ * and tries again at the next interval. When `false`, the heartbeat fires
+ * unconditionally regardless of workspace activity. The spawned heartbeat
+ * conversation lives in the DEDICATED heartbeat workspace, so it never
+ * counts as an "active agent" of the configured workspace (no self-block).
+ */
+ readonly inactiveOnly: boolean;
+ /** Custom system prompt for the heartbeat AI (empty = no system prompt). */
+ readonly systemPrompt: string;
+ /** Task prompt sent as the first user message when the heartbeat fires. */
+ readonly taskPrompt: string;
+ /** How often to fire, in minutes (default 30). */
+ readonly intervalMinutes: number;
+ /** Model name (`<credentialName>/<model>`), or empty string = server default. */
+ readonly model: string;
+ /** Reasoning-effort level, or `null` = inherit the workspace default. */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are
+ * optional; only the provided fields are applied. `reasoningEffort` accepts
+ * `null` to clear to the workspace default. `intervalMinutes` must be a positive
+ * integer (clamped server-side to a minimum of 1). An unrecognized
+ * `reasoningEffort` → HTTP 400.
+ */
+export interface UpdateHeartbeatRequest {
+ readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** The status of a single heartbeat run. */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/**
+ * One heartbeat run — created each time the heartbeat fires. A run tracks the
+ * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601
+ * timestamp. `status` is `"running"` while the turn is in flight,
+ * `"completed"` when the turn sealed normally, or `"stopped"` when the user
+ * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`).
+ */
+export interface HeartbeatRun {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */
+export interface HeartbeatRunsResponse {
+ readonly runs: readonly HeartbeatRun[];
+}
+
+/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export interface StopHeartbeatRunResponse {
+ readonly ok: true;
+}
+
+// ─── Search backend selection ─────────────────────────────────────────────────
+
+/**
+ * Re-exported wire types for the global search-backend selection
+ * (`GET/PUT /search/config`). The web frontend consumes these directly.
+ */
+export type {
+ SearchBackendAvailability,
+ SearchBackendConfigResponse,
+ UpdateSearchConfigRequest,
+} from "@dispatch/wire";
+
+// ─── Provider concurrency limits ──────────────────────────────────────────────
/**
* 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
+ * "opencode-1") 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;
- }[];
+ 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
@@ -1182,13 +1661,15 @@ export interface ConcurrencyLimitsResponse {
* sent immediately.
*/
export interface SetConcurrencyLimitRequest {
- readonly limit: number;
+ readonly limit: number;
}
+
/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */
export interface ConcurrencyLimitResponse {
- readonly providerId: string;
- readonly limit: number;
+ readonly providerId: string;
+ readonly limit: number;
}
+
/**
* One provider's live concurrency status.
*
@@ -1209,23 +1690,24 @@ export interface ConcurrencyLimitResponse {
* 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;
+ 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[];
+ readonly providers: readonly ConcurrencyStatusEntry[];
}
// ─── Provider concurrency cooldown ────────────────────────────────────────────
@@ -1237,8 +1719,8 @@ export interface ConcurrencyStatusResponse {
* When no cooldown was explicitly set, the server default (350ms) is returned.
*/
export interface ConcurrencyCooldownResponse {
- readonly providerId: string;
- readonly cooldownMs: number;
+ readonly providerId: string;
+ readonly cooldownMs: number;
}
/**
@@ -1248,31 +1730,9 @@ export interface ConcurrencyCooldownResponse {
* subsequently recycled slots.
*/
export interface SetConcurrencyCooldownRequest {
- readonly cooldownMs: number;
+ readonly cooldownMs: number;
}
-```
-
----
-
-## 2026-09-24 delta (search-backend-selection — ADDITIVE, no version bump)
-
-GLOBAL search-backend selection routes (the `web_search` tool's search-mode engine; scrape/crawl
-ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOVED entirely):
-
-- `GET /search/config` → `SearchBackendConfigResponse` (`{ backend, available: SearchBackendAvailability[] }`
- — re-exported from `@dispatch/wire`, see the wire delta below). When the `tool-web-search` extension
- is NOT loaded the route degrades like heartbeat: 200 with the defaults (`backend: "firecrawl"`,
- firecrawl-only availability).
-- `PUT /search/config` body `UpdateSearchConfigRequest` (`{ backend: string }`) → 200
- `SearchBackendConfigResponse` | 400 (`{ error }` — unknown or not-configured backend) | 503
- (service not loaded). The umans entry appears in `available` only when the backend has
- `UMANS_API_KEY`; an unconfigured backend is listed but `configured: false` — the UI disables it,
- it is never an error.
-
-## Account usage (`[email protected]`, verbatim)
-
-```ts
// ─── Account usage (Claude / Codex subscription quotas) ───────────────────────
//
// Subscription quota usage polled from each provider's first-party account
@@ -1418,4 +1878,5 @@ export interface AccountUsageResponse {
/** Server clock (epoch-ms) when this response was built — use it to compute window progress. */
readonly servedAt: number;
}
+
```