summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/transport-contract.reference.md1745
-rw-r--r--.dispatch/ui-contract.reference.md7
-rw-r--r--.dispatch/wire.reference.md131
3 files changed, 1410 insertions, 473 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 0582d94..eaa09a2 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,100 +1,27 @@
-# `@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). Regenerate whenever
-> it changes.
->
-> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains
-> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry`
-> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is
-> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced
-> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when
-> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` —
-> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user
-> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown
-> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when
-> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded;
-> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer,
-> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded.
-> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream
-> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3.
->
-> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the
-> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`),
-> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`),
-> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The
-> `concurrency` extension tracks/limits in-flight token-generating requests per provider with
-> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and
-> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j.
-> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface.
-> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]`
-> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/
-> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the
-> model natively accepts images; absent → the server's vision handoff transcribes images to text before the
-> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here).
->
-> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image`
-> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it
-> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the
-> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED
-> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to
-> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image
-> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: <description>` text
-> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global
-> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`),
-> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable
-> compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j.
->
-> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now
-> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new
-> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data
-> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`)
-> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in
-> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the
-> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j.
->
-> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the
-> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`),
-> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`),
-> `SetConversationComputerRequest` + `ConversationComputerResponse`
-> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest`
-> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/
-> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so
-> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See
-> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave —
-> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.)
->
-> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds
-> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint
-> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns
-> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`,
-> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`,
-> and optional `configSource`. Also adds the previously-missing `configSource` field to
-> `LspServerInfo`. See `frontend-mcp-status-handoff.md`.
->
-> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds
-> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and
-> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and
-> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation
-> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`,
-> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See
-> `frontend-system-prompt-handoff.md`.
+# `@dispatch/transport-contract` — in-repo reference
+Mirrored from the authorized public transport contract on 2026-10-24 for `feature/text-file-embeds`.
+Package version remains 0.26.0; local `file:` dependency unchanged.
+
+Additive text-file seam: `TextFileQuery`, `TextFileResponse`, and `TextFileContextResponse`
+for guarded, no-store local text display. Existing 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.
*
@@ -105,39 +32,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).
@@ -147,68 +82,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;
}
/**
@@ -221,21 +187,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;
}
/**
@@ -259,17 +267,24 @@ export interface ModelMetadata {
* tail sync = `?sinceSeq=<cursor>` (no limit); page older history in =
* `?beforeSeq=<oldestLoadedSeq>&limit=<k>`.
*
- * Seq numbering is **1-based and gap-free** (a CONTRACTUAL GUARANTEE — see
- * `StoredChunk` in `@dispatch/wire`): a client can derive "older chunks exist"
- * purely from `oldestLoaded.seq > 1`; there is deliberately no
- * `earliestSeq`/`hasOlder` response field.
+ * Seq is positive and never reused within a conversation, even when history is
+ * replaced. The retained log need not start at 1, so `oldestLoaded.seq > 1`
+ * does not prove older chunks exist. An empty/short backfill page can establish
+ * that no earlier chunks remain.
+ *
+ * `historyEpoch` is the persisted replacement count (missing storage value = 0).
+ * Every successful replacement, including an empty one, increments it once.
+ * On a different epoch, clients invalidate their conversation cache and refetch
+ * with `sinceSeq=0` (optionally windowed), even if this response has no chunks.
*
* `chunks` is the RAW, append-order, seq-ordered slice of the conversation log
* selected + windowed as above. It is NOT reconciled: a dangling tool-call is
* returned as-is (rendered as an interrupted call). Reconciliation is a
* turn-path concern — the server repairs history only when it feeds a provider,
* never on this read path — which is what preserves the per-chunk `seq` cursor
- * invariant (a synthesized repair chunk would have no seq).
+ * invariant (a synthesized repair chunk would have no seq). Provider-opaque
+ * thinking replay fields (`signature` and `redactedData`) are omitted from
+ * this client-facing response; chunk presence, text, order, and seq are intact.
*
* `latestSeq` is the `seq` of the LAST chunk in this response, or — when the
* slice is empty (the client is already caught up) — the requested `sinceSeq`
@@ -284,8 +299,9 @@ export interface ModelMetadata {
* the store contract.)
*/
export interface ConversationHistoryResponse {
- readonly chunks: readonly StoredChunk[];
- readonly latestSeq: number;
+ readonly chunks: readonly StoredChunk[];
+ readonly latestSeq: number;
+ readonly historyEpoch: number;
}
/**
@@ -304,15 +320,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`. */
@@ -326,16 +342,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;
}
/**
@@ -349,21 +365,91 @@ 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[];
+}
+
+// ─── Live conversation text files ────────────────────────────────────────────
+
+/**
+ * Response body for `GET /conversations/:id/text-file-context` — the effective,
+ * authoritative conversation/workspace context used by the text-file endpoint,
+ * not merely the conversation's persisted cwd/computer overrides.
+ *
+ * This minimal preflight shares context resolution with the file read, without
+ * starting/querying LSP or reading any files. An unknown conversation → HTTP 404.
+ * It is live context, not a snapshot; responses use `Cache-Control: no-store`.
+ * Clients should load it before requesting a text file and supply BOTH returned
+ * values as guards in `TextFileQuery` (encode null as the empty string).
+ */
+export interface TextFileContextResponse {
+ readonly conversationId: string;
+ /** Effective authoritative cwd, or null when unset (no process-cwd fallback). */
+ readonly cwd: string | null;
+ /** Effective authoritative computer target, or null for local execution. */
+ readonly computerId: string | null;
+}
+
+/**
+ * Query parameters for `GET /conversations/:id/text-file`.
+ *
+ * Pass the RAW literal path to `URLSearchParams`; only the URL query decoder
+ * decodes it. The server MUST NOT percent-decode the resulting path a second
+ * time (a literal `%20` in a filename stays `%20`, not a space).
+ *
+ * Resolution uses the authoritative conversation/workspace context, never a
+ * client-supplied context override. A relative path requires an authoritative
+ * cwd; there is no process-cwd fallback. Only local regular files are supported,
+ * not ordinary network/protocol fetching. A remote computer target → HTTP 422.
+ *
+ * Guards compare against the effective context used for this read. An omitted
+ * guard skips that comparison; an empty string asserts null (cwd unset or
+ * computer local). A mismatch → HTTP 409. Browsers should supply BOTH guards
+ * after loading `TextFileContextResponse` to detect context changes.
+ */
+export interface TextFileQuery {
+ /** Literal absolute or cwd-relative POSIX file path, not a URL to fetch. */
+ readonly path: string;
+ /** Expected effective cwd; empty string asserts unset, omitted skips guard. */
+ readonly expectedCwd?: string;
+ /** Expected effective computer; empty string asserts local, omitted skips guard. */
+ readonly expectedComputerId?: string;
+}
+
+/**
+ * Response body for `GET /conversations/:id/text-file` — a live file read, NOT a
+ * persisted snapshot or attachment. Responses use `Cache-Control: no-store`.
+ *
+ * Only regular files containing strict UTF-8 without NUL or binary control
+ * characters are accepted. Reads are bounded to 256 KiB (262144 bytes) under
+ * the current policy, with no truncation. Invalid paths, nonregular files, or
+ * nontext content → HTTP 400; unknown conversations or missing files → HTTP
+ * 404; context-guard mismatch → HTTP 409; oversized files → HTTP 413; remote
+ * computer targets (unsupported) → HTTP 422.
+ */
+export interface TextFileResponse {
+ /** Resolved absolute POSIX file path. */
+ readonly path: string;
+ /** Complete decoded UTF-8 file content, never truncated. */
+ readonly text: string;
+ readonly format: "markdown" | "text";
+ /** Original UTF-8 file size in bytes (not JavaScript string length). */
+ readonly bytes: number;
+ /** Maximum accepted file size in bytes; currently 262144 (256 KiB). */
+ readonly maxBytes: number;
}
// ─── 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;
}
/**
@@ -377,8 +463,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 ────────────────────────────────────────
@@ -389,8 +475,8 @@ export interface SetCwdRequest {
* `"high"`).
*/
export interface ReasoningEffortResponse {
- readonly conversationId: string;
- readonly reasoningEffort: ReasoningEffort | null;
+ readonly conversationId: string;
+ readonly reasoningEffort: ReasoningEffort | null;
}
/**
@@ -400,19 +486,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;
}
/**
@@ -421,9 +515,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) ──────────────────────────────────
@@ -443,9 +544,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 ───────────────────────────────────────────────────
@@ -460,8 +561,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;
}
/**
@@ -472,7 +573,7 @@ export interface SystemPromptTemplateResponse {
* conversations use the new template on their first turn.
*/
export interface SetSystemPromptTemplateRequest {
- readonly template: string;
+ readonly template: string;
}
/**
@@ -481,43 +582,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) ─────────────────────────────────────────────────
@@ -535,15 +632,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;
}
/**
@@ -555,9 +655,30 @@ 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[];
+}
+
+/**
+ * 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 ──────────────────────────────────────────────
@@ -567,39 +688,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 ──────────────────────────────────────────────────────
@@ -608,29 +729,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[];
}
/**
@@ -644,17 +765,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;
}
/**
@@ -666,26 +787,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 ───────────────────────────────────────────────────────
@@ -700,7 +821,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;
}
/**
@@ -710,8 +833,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";
}
/**
@@ -720,9 +860,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;
}
/**
@@ -736,14 +906,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;
}
/**
@@ -753,30 +926,99 @@ 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
}
/**
@@ -784,40 +1026,69 @@ export interface ChatQueueMessage {
* ops. A server discriminates on `type`.
*/
export type WsClientMessage =
- | SurfaceClientMessage
- | ChatSendMessage
- | ChatSubscribeMessage
- | ChatUnsubscribeMessage
- | ChatQueueMessage;
+ | 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;
+ 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;
}
/**
@@ -826,28 +1097,306 @@ 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;
}
/**
* Broadcast to all connected WS clients when a conversation's history has been
- * compacted (summarized). The frontend should reload the conversation history
- * via `GET /conversations/:id` to reflect the compacted state.
+ * compacted (summarized). `historyEpoch` is the persisted replacement count:
+ * missing storage value = 0; every successful replacement increments it once,
+ * including empty replacements. On a different epoch, clients invalidate their
+ * conversation cache and refetch `GET /conversations/:id?sinceSeq=0`.
*/
export interface ConversationCompactedMessage {
- readonly type: "conversation.compacted";
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly type: "conversation.compacted";
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+ 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;
}
/**
@@ -857,7 +1406,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[];
}
/**
@@ -867,9 +1416,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;
}
/**
@@ -877,22 +1426,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;
}
/**
@@ -900,10 +1460,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;
}
/**
@@ -911,15 +1471,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 ───────────────────────────────────────────────────────────────
@@ -930,28 +1490,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;
}
/**
@@ -960,12 +1525,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
@@ -975,7 +1540,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[];
}
/**
@@ -992,10 +1557,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;
}
/**
@@ -1007,7 +1572,7 @@ export interface ComputerStatusResponse {
* a 400). Mirrors the cwd/model PUT clear semantics.
*/
export interface SetConversationComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -1016,8 +1581,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;
}
/**
@@ -1027,7 +1592,7 @@ export interface ConversationComputerResponse {
* `computerId` of their own inherit this.
*/
export interface SetWorkspaceDefaultComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -1037,25 +1602,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;
+}
+
+// ─── 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;
}
-// ── Provider concurrency limits ([email protected]) ───────────────────
+/** 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
@@ -1063,13 +1732,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.
*
@@ -1090,23 +1761,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 ────────────────────────────────────────────
@@ -1118,8 +1790,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;
}
/**
@@ -1129,7 +1801,152 @@ export interface ConcurrencyCooldownResponse {
* subsequently recycled slots.
*/
export interface SetConcurrencyCooldownRequest {
- readonly cooldownMs: number;
+ readonly cooldownMs: number;
}
-```
+// ─── Account usage (Claude / Codex subscription quotas) ───────────────────────
+//
+// Subscription quota usage polled from each provider's first-party account
+// API (Claude `GET /api/oauth/usage`, Codex `GET /backend-api/wham/usage`),
+// cached server-side. Distinct from per-turn token `Usage`: these are the
+// account's plan allowance windows, never estimated from token counts.
+
+/**
+ * The subscription accounts the usage service reports on. `hyper` is a daily
+ * Hypercredit allowance (Hyper's API reports only the balance — the allowance
+ * and reset time are user settings, see `AccountUsageDailyCredits`).
+ */
+export type AccountUsageProviderId = "claude" | "codex" | "hyper";
+
+/**
+ * Freshness/availability of one provider's report:
+ * - `fresh` — the last refresh succeeded and is within the cache TTL.
+ * - `stale` — the latest refresh FAILED; `limits` are the last good
+ * report (≤ 24h old), see `fetchedAt` / `error`.
+ * - `unavailable` — refresh failed and there is no last-good report.
+ * - `unsupported` — a credential exists but cannot query subscription usage
+ * (e.g. a plain Anthropic API key).
+ * - `not-configured` — no credential / the auth extension is not loaded.
+ */
+export type AccountUsageState =
+ | "fresh"
+ | "stale"
+ | "unavailable"
+ | "unsupported"
+ | "not-configured";
+
+/** Severity of a single limit: `warning` ≥ 90% used, `exhausted` ≥ 100%. */
+export type AccountUsageLimitStatus = "ok" | "warning" | "exhausted" | "unknown";
+
+/**
+ * One quota meter. Percent meters have `unit: "percent"`, `limit: 100`.
+ * Spend meters (`kind: "spend"`, `unit: "usd"`) may have `limit: null` when the
+ * cap is unlimited/unset — never treat that as a zero-dollar cap.
+ */
+export interface AccountUsageLimit {
+ /** Stable id, e.g. `claude:5h`, `claude:7d:opus`, `codex:primary`, `codex:spark:secondary`. */
+ readonly id: string;
+ /** Human label, e.g. "5 Hour", "7 Day (Opus)", "Spark · 5 hours". */
+ readonly label: string;
+ readonly kind: "window" | "spend";
+ /** Window id, e.g. `5h`, `7d`, `30d`, `extra`. */
+ readonly windowId: string;
+ /** True for account-wide windows that gate every request. */
+ readonly shared: boolean;
+ /** Model family / metered-feature slug for scoped meters, else null. */
+ readonly tier: string | null;
+ /** Window length in ms (null when unknown, e.g. spend meters). */
+ readonly durationMs: number | null;
+ /** Epoch-ms when the window resets (null when unknown). */
+ readonly resetsAt: number | null;
+ /** `credits` = Hypercredits (`limit` = the daily allowance). */
+ readonly unit: "percent" | "usd" | "credits";
+ readonly used: number;
+ readonly limit: number | null;
+ readonly remaining: number | null;
+ /** 0..1 (null when there is no cap). */
+ readonly usedFraction: number | null;
+ readonly remainingFraction: number | null;
+ readonly status: AccountUsageLimitStatus;
+}
+
+/** Codex paid-credit details (from `/wham/usage` `credits` + `spend_control`). */
+export interface AccountUsageCredits {
+ readonly hasCredits: boolean | null;
+ readonly unlimited: boolean | null;
+ readonly overageLimitReached: boolean | null;
+ /** Remaining credit balance as reported (string or number upstream; normalized to number). */
+ readonly balance: number | null;
+ readonly spendControlReached: boolean | null;
+}
+
+/** One provider's usage report as served to clients. Never carries credentials. */
+export interface AccountUsageReport {
+ readonly provider: AccountUsageProviderId;
+ /** Display name: "Claude" / "Codex". */
+ readonly label: string;
+ readonly state: AccountUsageState;
+ readonly account: {
+ readonly accountId: string | null;
+ readonly email: string | null;
+ readonly planType: string | null;
+ } | null;
+ readonly limits: readonly AccountUsageLimit[];
+ /**
+ * Plan-level verdict (Codex): `limitReached` = plan allowance consumed;
+ * `canServe` = the account can still serve requests (credits may fund
+ * overage). Null when unknown (Claude reports per-window only).
+ */
+ readonly limitReached: boolean | null;
+ readonly canServe: boolean | null;
+ /** True when Codex credits fund requests past an exhausted plan window. */
+ readonly creditOverage: boolean;
+ readonly credits: AccountUsageCredits | null;
+ /** Daily credit allowance details (Hyper only; null for other providers). */
+ readonly dailyCredits: AccountUsageDailyCredits | null;
+ /** Epoch-ms of the report's data (last successful fetch), null if never. */
+ readonly fetchedAt: number | null;
+ /** Epoch-ms of the last refresh attempt (success or failure), null if never. */
+ readonly lastAttemptAt: number | null;
+ /** Epoch-ms after which the server will refresh on the next request. */
+ readonly nextRefreshAt: number | null;
+ /** Last refresh failure message (sanitized, no secrets), null on success. */
+ readonly error: string | null;
+}
+
+/**
+ * Daily credit allowance (Hyper). Hyper's `GET /v1/credits` returns only the
+ * balance, so `allowance` (default 250) and the reset time are settings:
+ * - `resetAnchor`: an epoch-ms instant of ANY past or future refresh; resets
+ * recur every 24h from it (`resetsAt` on the limit is the next one).
+ * - `resetSource`: `manual` (user-set), `detected` (inferred from an observed
+ * balance refill — approximate, ± the poll interval), or null (unknown ⇒ no
+ * `resetsAt`, so no pace marker).
+ */
+export interface AccountUsageDailyCredits {
+ /** Current balance as reported (may exceed `allowance` with purchased bundles). */
+ readonly balance: number;
+ readonly allowance: number;
+ readonly resetAnchor: number | null;
+ readonly resetSource: "manual" | "detected" | null;
+}
+
+/**
+ * Body of `PUT /usage/:provider/settings` (only `hyper` has settings). Omitted
+ * fields are unchanged. `dailyAllowance` must be a positive integer;
+ * `resetAnchor` is an epoch-ms refresh instant, or `null` to clear the manual
+ * time (falling back to a detected one, if any). Responds with the provider's
+ * refreshed `AccountUsageReport`; 400 invalid body, 404 provider without settings.
+ */
+export interface UpdateAccountUsageSettingsRequest {
+ readonly dailyAllowance?: number;
+ readonly resetAnchor?: number | null;
+}
+
+/** Response of `GET /usage` — one report per provider, in fixed order (claude, codex, hyper). */
+export interface AccountUsageResponse {
+ readonly providers: readonly AccountUsageReport[];
+ /** Server clock (epoch-ms) when this response was built — use it to compute window progress. */
+ readonly servedAt: number;
+}
+```
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
index d751af8..15bb785 100644
--- a/.dispatch/ui-contract.reference.md
+++ b/.dispatch/ui-contract.reference.md
@@ -8,6 +8,13 @@
> **Orchestrator:** this is a SNAPSHOT of `[email protected]` — regenerate it whenever
> `ui-contract` changes.
>
+> **2026-09-03 delta (plugin system — plan 06):** `SurfaceCatalogEntry` gains an OPTIONAL
+> `pluginId?: string` — the id of the plugin contributing the surface (plan 06's
+> surface→plugin attribution for the per-workspace catalog filter). Absent on older
+> backends / host-contributed surfaces — treat as "no plugin" (never filtered). The
+> FE does not branch on it yet (the catalog is server-filtered via `workspace.set`
+> + `extensions-changed` re-pushes); it is available for future attribution UI.
+>
> **2026-06-12 delta (CR-2/CR-4 handoff — package bumped `0.1.0` → `0.2.0`):** adds the optional
> `scope?: "global" | "conversation"` to `SurfaceCatalogEntry` so a client can skip re-subscribing
> GLOBAL surfaces on a conversation switch. ABSENT means assume conversation-scoped (the
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index b430a45..777a2ba 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -1,11 +1,50 @@
# `@dispatch/wire` — in-repo reference (read THIS, not node_modules)
+> **2026-10-22 delta (reasoning block boundaries — ADDITIVE, no version bump):** `AgentEvent`
+> gains `TurnReasoningEndEvent` `{ type: "reasoning-end", conversationId, turnId }`. The kernel emits
+> one per provider reasoning block (Anthropic signs each thinking block separately), carrying NO
+> opaque payload (signature/redacted data never leave the server). A live consumer splits thinking
+> exactly as HTTP history persists it: the open thinking chunk closes; with none open (a redacted
+> empty block) an empty thinking chunk is implied. Providers without block structure never emit it.
+> The FE reducer mirrors the kernel's chunk builder; the old merged-run alignment stays as a fallback
+> for older servers/replays. See `backend-handoff.md` and the backend's
+> `notes/reasoning-end-live-event-plan.md`.
+>
+> **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/wire` package source so headless FE agents can read the wire
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes.
>
+> **2026-09-20 delta (workspace archiving — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a
+> required `archived: boolean` (defaults to `false` on creation; legacy rows normalize to `false`). An archived
+> workspace is HIDDEN from the home grid WITHOUT deleting its data (conversations/settings untouched) and can be
+> unarchived at any time. Toggled via dedicated `PUT`/`DELETE /workspaces/:id/archive` endpoints (no body; both
+> create-on-miss and return the updated `Workspace`; both reject `"default"` with 409). `GET /workspaces` takes an
+> optional `?archived=` query: `true` → archived only, `all` → every workspace, absent/other → non-archived only
+> (the home grid's default). `PUT /workspaces/:id` does NOT accept an `archived` field. Implemented in BOTH repos
+> of the `feature/archive-workspaces` worktree (see `backend-handoff.md`).
+>
+> **2026-08-25 delta (per-conversation model on ConversationMeta — ADDITIVE, no version bump):**
+> `ConversationMeta` gains an optional `model?: string` (`<credential>/<model>`, absENT = default
+> provider model). Surfaced on `GET /conversations` and the `conversation.open` WS broadcast so the
+> FE can stamp a tab with the conversation's ACTUAL model instead of the globally-selected one
+> (fix-toolcall-duplicate-keys plan Part 4 — prevents the stored model being overwritten by
+> `activeModel`). See `transport-contract.reference.md` for the `ConversationOpenMessage.model`
+> addition.
+>
+> **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** `@dispatch/wire` now exports two
+> named-agent shapes the FE consumes (`named-agents-plan.md`): `NamedAgent`
+> `{ id, name, model }` (a user-curated model preset; `model` = canonical `<provider>/<model>`) and
+> `NamedAgentSelectionSnapshot` `{ namedAgentId, namedAgentName, model }` (the snapshot a conversation
+> retains when selected via a Named Agent — plan §7). These mirror the backend's `packages/named-agents`.
+> The FE re-exports them from this package (`features/named-agents/logic/types.ts`) rather than
+> duplicating. Transport-contract also carries the `/named-agents` HTTP request/response types +
+> `ChatRequest.namedAgent` / `ModelResponse.namedAgent` / `SetModelRequest.namedAgent` (see
+> `transport-contract.reference.md`).
+>
> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a
> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive
> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces
@@ -231,26 +270,37 @@ export interface ChatMessage {
/**
* A persisted chunk plus its sync metadata. The append-only conversation log
- * stamps every chunk with a **1-based**, monotonic, gap-free, per-conversation
+ * stamps every chunk with a positive, monotonic, per-conversation
* `seq` (the sync cursor, assigned in append order) and records the `role` of
* the message it belongs to. This makes a flat seq-ordered stream both
* incrementally syncable ("give me chunks after seq N") and regroupable into
* messages by the client.
*
- * The 1-based start is a CONTRACTUAL GUARANTEE (not an implementation detail):
- * a conversation's first chunk is always `seq === 1` and numbering never skips,
- * so a client holding only a windowed suffix of the log can derive "older
- * chunks exist server-side" purely from `oldestLoaded.seq > 1` — no separate
- * has-older flag is needed (or provided). `chunk` is the content unit — `Chunk` carries no storage/sync cursor
+ * Seq is never reused within a conversation, including after compaction replaces
+ * its history. The retained log need not start at 1 or be gap-free; a client
+ * cannot infer that older chunks exist solely from `oldestLoaded.seq > 1`.
+ * `chunk` is the content unit — `Chunk` carries no storage/sync cursor
* (`seq` lives here on the envelope, not on the chunk, since it is assigned by
* the store and the provider has no use for it). A chunk MAY still carry
* generation provenance assigned at production time (e.g. a tool chunk's
* `stepId`), which is intrinsic to the content and so travels with it.
*/
export interface StoredChunk {
- readonly seq: number;
- readonly role: Role;
- readonly chunk: Chunk;
+ readonly seq: number;
+ readonly role: Role;
+ readonly chunk: Chunk;
+ /**
+ * The turn that produced this chunk — the same `turnId` carried on every
+ * live `AgentEvent` of that turn (the turn's opening user message, any
+ * mid-turn steering message, and every assistant/tool chunk the turn
+ * generated). The join key between transcript content and the turn's
+ * `TurnMetrics`, so a client places per-turn metrics EXACTLY (no positional
+ * guessing) — including while a turn is in flight and when older history has
+ * been unloaded. Optional: absent on rows persisted before this field
+ * existed and on chunks written outside any turn (e.g. a compaction summary).
+ * A consumer must tolerate its absence.
+ */
+ readonly turnId?: string;
}
// ─── Reasoning effort ───────────────────────────────────────────────────────
@@ -374,6 +424,7 @@ export type AgentEvent =
| TurnInputEvent
| TurnTextDeltaEvent
| TurnReasoningDeltaEvent
+ | TurnReasoningEndEvent
| TurnToolCallEvent
| TurnToolResultEvent
| TurnToolOutputEvent
@@ -434,6 +485,21 @@ export interface TurnReasoningDeltaEvent {
readonly delta: string;
}
+/**
+ * End of one reasoning / thinking block. Emitted once per block by providers with
+ * block-structured reasoning (e.g. Anthropic, whose consecutive thinking blocks — each
+ * separately signed — persist as separate thinking chunks). Lets a live consumer split
+ * thinking exactly as history does: the open thinking chunk closes; if none is open (a
+ * redacted/empty block with no deltas) an EMPTY thinking chunk is implied. Carries no
+ * content: the opaque replay payloads (signature / redacted data) never leave the
+ * server. Providers without block structure never emit it (deltas merge as before).
+ */
+export interface TurnReasoningEndEvent {
+ readonly type: "reasoning-end";
+ readonly conversationId: string;
+ readonly turnId: string;
+}
+
/** The model has requested a tool to be run. */
export interface TurnToolCallEvent {
readonly type: "tool-call";
@@ -670,6 +736,15 @@ export interface ConversationMeta {
* that have never been compacted.
*/
readonly compactedFrom?: string;
+ /**
+ * The persisted per-conversation model name (`<credential>/<model>`), when
+ * one has been set. Absent/null means the conversation uses the default
+ * provider model. Surfaced on the open-conversation list + `conversation.open`
+ * broadcast so a client can stamp a tab with the conversation's ACTUAL model
+ * instead of the currently-selected global model (fix-toolcall-duplicate-keys
+ * plan Part 4).
+ */
+ readonly model?: string;
}
// ─── Compaction ──────────────────────────────────────────────────────────────
@@ -722,6 +797,14 @@ export interface Workspace {
* Defaults to `false` on creation.
*/
readonly starred: boolean;
+ /**
+ * Whether the workspace is archived by the user. Archived workspaces are
+ * HIDDEN from the home grid (`GET /workspaces` excludes them by default;
+ * `?archived=` opts back in) without deleting their data — the workspace,
+ * its conversations, and its settings are untouched, and it can be
+ * unarchived at any time. Defaults to `false` on creation.
+ */
+ readonly archived: boolean;
/** Epoch-ms when the workspace was first created. */
readonly createdAt: number;
/** Epoch-ms of the most recent conversation activity in this workspace. */
@@ -778,3 +861,33 @@ export interface ComputerEntry extends Computer {
readonly usageCount: number;
}
```
+
+---
+
+## 2026-09-24 delta (search-backend-selection — ADDITIVE)
+
+The global search-backend selection wire types (`GET/PUT /search/config`, GLOBAL — not
+workspace-scoped) shipped in `@dispatch/wire` (transport-contract re-exports them):
+
+```ts
+/** The selected backend id. `"none"` = search off (scrape/crawl keep working). */
+export type SearchBackendId = "firecrawl" | "umans" | "none";
+
+/** One dropdown entry; `configured` is the availability gate (umans: UMANS_API_KEY resolves; firecrawl: always). */
+export interface SearchBackendAvailability {
+ readonly id: string;
+ readonly label: string;
+ readonly configured: boolean;
+}
+
+/** Response of `GET /search/config` — the selection + every registered backend's availability. */
+export interface SearchBackendConfigResponse {
+ readonly backend: string;
+ readonly available: readonly SearchBackendAvailability[];
+}
+
+/** Body of `PUT /search/config` — select a backend by id (or `"none"`). */
+export interface UpdateSearchConfigRequest {
+ readonly backend: string;
+}
+```