diff options
Diffstat (limited to '.dispatch')
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 1745 | ||||
| -rw-r--r-- | .dispatch/ui-contract.reference.md | 7 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 131 |
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; +} +``` |
