summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 16:28:07 +0900
committerAdam Malczewski <[email protected]>2026-06-12 16:28:07 +0900
commit4001274e3ba25a3946df1e9f2dc82ca6781cd2bf (patch)
tree24af95e69bda5c38ab7eefd6b71d55b4c247040a /.dispatch
parente6f6bd86eab07954d8f06e740659969c3dfecc7f (diff)
downloaddispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.tar.gz
dispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.zip
feat(cache-warming): consume CR-4 lifecycle — tab-close cancel + scope-aware subscriptions
- closeTab now POSTs /conversations/:id/close (abort in-flight turn + stop/disable warming server-side); disconnect still leaves both running ([email protected]) - syncSubscriptions honors catalog scope ([email protected]): global surfaces are not re-subscribed on conversation switch - fix(ws): the surface-message parser dropped the conversationId echo (CR-4d was ours, not the backend's) — preserved + unit-tested - secondsUntilNext: 3s stale guard — a past nextWarmAt renders as waiting, not 0s - re-pinned + re-mirrored [email protected] / [email protected] - scripts/probe-cache-warming.ts: live CR-4 probe (default-off, future nextWarmAt, repeated warms, mid-turn close abort, idempotent re-close) — 17/17 against bin/up
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/transport-contract.reference.md294
-rw-r--r--.dispatch/ui-contract.reference.md87
2 files changed, 271 insertions, 110 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 86eac50..e6ab799 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,10 +5,25 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
> this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (CR-3 user-message shipped). Depends on
-> `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
+> **Orchestrator:** SNAPSHOT of `[email protected]` (CR-4 cache-warming lifecycle shipped).
+> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
> `ui-contract.reference.md`).
>
+> **2026-06-12 delta (CR-4 cache-warming lifecycle — package bumped `0.8.0` → `0.9.0`):** adds
+> `POST /conversations/:id/close` (`CloseConversationResponse`) — the EXPLICIT "user closed this
+> conversation's tab" affordance, distinct from a socket disconnect / `chat.unsubscribe` (which
+> still NEVER touch the turn or the warming schedule). Closing (1) aborts any in-flight turn — the
+> kernel stops at the next event boundary, partial messages are PERSISTED, and the turn SEALS
+> normally with `finishReason: "aborted"` (watchers receive `done` then `turn-sealed`, so a
+> stream-derived "generating" flag clears with no special-casing) — and (2) stops + DISABLES
+> cache-warming for the conversation (persisted OFF; reopening does not resume warming). Idempotent:
+> closing an idle/unknown conversation is `200` with `abortedTurn: false`. Backend behavior fixes
+> riding EXISTING shapes (no other contract change): warming now defaults OFF for a new conversation
+> (240s interval default kept; re-enable restores the persisted interval); post-warm surface updates
+> now carry the FUTURE `nextWarmAt` (notify-before-reschedule fixed); `nextWarmAt: null` is pushed on
+> `turn-start` (nothing scheduled while generating) and when warming is/became disabled. Caveat: the
+> warming opt-in is NOT yet re-hydrated across a backend restart (reads disabled until toggled again).
+>
> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.7.0` → `0.8.0`):** NO transport
> shape change — it re-exports `AgentEvent` (which `chat.delta` / `/chat` NDJSON carry), and that union
> gained the additive `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`), the
@@ -29,7 +44,7 @@
> persist across turns. FE consumes via the `chat` feature + app store (re-subscribe every open
> conversation on (re)connect + page load; derive a "running" state structurally from
> `turn-start`…no-`done`/`turn-sealed`-yet). OUT of scope: per-step crash-resume, concurrent-send
-> arbitration, explicit stop.
+> arbitration.
>
> **2026-06-12 delta (context-size handoff — package bumped `0.5.0` → `0.6.0`, depends on
> `[email protected]`):** no NEW transport shape — the optional `contextSize?: number` rides the
@@ -85,6 +100,9 @@
- `POST /chat/warm` — body `WarmRequest` (JSON) → `200 WarmResponse` (cache-warm usage incl.
`cachePct`); `409 { error }` when the conversation is currently generating; `400 { error }` on a
missing/invalid `conversationId`. The warm is NEVER persisted/streamed/folded into real usage.
+- `POST /conversations/:id/close` — no body → `200 CloseConversationResponse`. The EXPLICIT tab-close
+ affordance: aborts any in-flight turn (persists the partial; seals with `finishReason: "aborted"`)
+ AND stops + disables cache-warming (persisted OFF). Idempotent (`abortedTurn: false` when idle/unknown).
- `GET /metrics/throughput?period=day|week|month&date=<...>` — `ThroughputResponse` (token-weighted
tokens/sec per model over the window). Not part of cache-warming; listed for completeness.
- `GET /conversations/:id/cwd` — `CwdResponse` (`cwd` is `null` until set).
@@ -97,18 +115,23 @@
(`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive
`WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`**
(seq lives only on `StoredChunk`, obtained via the `sinceSeq` sync after `turn-sealed`).
-- DEFERRED (not built; do not depend on): `GET /conversations` (list), `POST /conversations/:id/cancel`.
+- DEFERRED (not built; do not depend on): `GET /conversations` (list). (The former deferred
+ `POST /conversations/:id/cancel` is superseded by `POST /conversations/:id/close`.)
```ts
/**
* Transport contract — the typed description of Dispatch's client–server API
- * (HTTP + WebSocket). Types-only (zero runtime). Each side owns its own
- * (de)serialization — the contract is the SHAPES, not the codec.
+ * (HTTP + WebSocket).
+ *
+ * This package is types-only (zero runtime). It is the single shared surface
+ * every client imports to know how to talk to the backend. Each side owns its
+ * OWN (de)serialization: the contract is the SHAPES, not the codec. The
+ * streaming response payload is the kernel's `AgentEvent` union, re-exported
+ * here so a client has one import for the whole wire.
*
- * The WebSocket carries BOTH chat ops (here) and surface ops (in
+ * The WebSocket carries BOTH chat ops (defined here) and surface ops (defined in
* `@dispatch/ui-contract`) over one connection; the unified `WsClientMessage` /
- * `WsServerMessage` unions below compose them. Chat ops are new, non-colliding
- * `type` variants (`chat.*`) — the shipped surface protocol is unchanged.
+ * `WsServerMessage` unions below compose them.
*/
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -124,19 +147,35 @@ export type { AgentEvent, StepMetrics, StoredChunk, TurnMetrics } from "@dispatc
* response header (useful when `conversationId` was omitted).
*/
export interface ChatRequest {
- /** The conversation to continue. Omit to start fresh — server mints an id (X-Conversation-Id). */
+ /**
+ * The conversation to continue. Omit to start a fresh conversation — the
+ * server mints an id and returns it via the `X-Conversation-Id` header.
+ */
readonly conversationId?: string;
+
/** The user's message text for this turn. */
readonly message: string;
- /** Model name in `<credentialName>/<model>` form (one of `GET /models`). Omit = server default. */
+
+ /**
+ * The model to use, as a model name in `<credentialName>/<model>` form — one
+ * of the exact strings returned by `GET /models`. Omit to use the server's
+ * default credential + model.
+ */
readonly model?: string;
- /** Working directory for this turn's tool execution. Defaults server-side. Not part of the prompt. */
+
+ /**
+ * Working directory for this turn's tool execution. Defaults server-side when
+ * omitted. Forwarded to tools for path resolution; never part of the model
+ * prompt (so it does not affect prompt caching).
+ */
readonly cwd?: string;
}
/**
- * Response body for `GET /models` — the model catalog. Each entry is a model
- * name in `<credentialName>/<model>` form (exactly `ChatRequest.model`).
+ * Response body for `GET /models` — the model catalog.
+ *
+ * Each entry is a model name in `<credentialName>/<model>` form: exactly the
+ * string a client passes back as `ChatRequest.model`.
*/
export interface ModelsResponse {
readonly models: readonly string[];
@@ -144,14 +183,22 @@ export interface ModelsResponse {
/**
* Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental
- * read-side history endpoint a long-lived client uses to (re)hydrate cheaply.
+ * read-side history endpoint a long-lived client uses to (re)hydrate a
+ * conversation cheaply.
+ *
+ * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log
+ * with `seq > sinceSeq` (or the whole log when `sinceSeq` is omitted/0). 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).
*
- * `chunks` is the RAW, append-order, seq-ordered slice with `seq > sinceSeq`
- * (or the whole log when `sinceSeq` is omitted/0). NOT reconciled: a dangling
- * tool-call is returned as-is. `latestSeq` is the `seq` of the LAST chunk, or —
- * when the slice is empty (caught up) — the requested `sinceSeq` (0 for a full
- * read of an empty conversation). After applying, the client's new cursor is
- * always `latestSeq`; empty `chunks` means "nothing new past your cursor".
+ * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the
+ * slice is empty (the client is already caught up) — the requested `sinceSeq`
+ * (0 for a full read of an empty conversation). So after applying the response a
+ * client's new cursor is always `latestSeq`, and an empty `chunks` means
+ * "nothing new past your cursor".
*/
export interface ConversationHistoryResponse {
readonly chunks: readonly StoredChunk[];
@@ -163,12 +210,6 @@ export interface ConversationHistoryResponse {
* (and per-step) token + timing metrics for a conversation, for a client
* reopening a past conversation to render historical usage/latency.
*
- * This is a SEPARATE axis from the two other read concerns and is deliberately
- * its own endpoint: the live `usage`/`step-complete`/`done` events are transient
- * (not persisted), and `ConversationHistoryResponse` carries seq-cursor chunk
- * CONTENT. Metrics are keyed per TURN (not per chunk) and so are not seq-filtered
- * — hence a sibling route rather than a field on the history response.
- *
* `turns` is every SEALED turn's `TurnMetrics` in turn order. A turn appears only
* after its metrics were persisted (post-seal); an in-flight or unsealed turn is
* absent until then.
@@ -180,63 +221,46 @@ export interface ConversationMetricsResponse {
/** The aggregation window for `GET /metrics/throughput`. */
export type ThroughputPeriod = "day" | "week" | "month";
-/** One model's token-weighted throughput over a period. */
+/**
+ * One model's throughput over a period. `tokensPerSecond` is the TOKEN-WEIGHTED
+ * average — `Σ(output tokens) / Σ(generation seconds)` across the period's
+ * turns — so larger turns count proportionally more than smaller ones.
+ * Generation time is the model's pure decode time (it excludes tool-execution
+ * waits).
+ */
export interface ThroughputModelStat {
+ /** The model name in `<credentialName>/<model>` form (as selected). */
readonly model: string;
+ /** Token-weighted average tokens/second over the period. */
readonly tokensPerSecond: number;
+ /** Total output tokens generated across the period's turns. */
readonly totalOutputTokens: number;
+ /** Total pure generation time across the period's turns, in milliseconds. */
readonly totalGenMs: number;
+ /** Number of turns that contributed. */
readonly turns: number;
}
-/** Response body for `GET /metrics/throughput?period=...&date=...`. */
+/**
+ * Response body for
+ * `GET /metrics/throughput?period=day|week|month&date=<...>`.
+ *
+ * `date` is `YYYY-MM-DD` for day/week (week = the ISO Mon–Sun week containing
+ * that date) and `YYYY-MM` for month. Boundaries are computed in the server's
+ * local timezone; `start`/`end` are the resolved half-open `[start, end)` range
+ * in epoch-ms. `models` lists every model active in the window, sorted by
+ * `tokensPerSecond` descending.
+ */
export interface ThroughputResponse {
readonly period: ThroughputPeriod;
readonly date: string;
- readonly start: number; // inclusive window start, epoch-ms
- readonly end: number; // exclusive window end, epoch-ms
+ /** Inclusive start of the window, epoch-ms. */
+ readonly start: number;
+ /** Exclusive end of the window, epoch-ms. */
+ readonly end: number;
readonly models: readonly ThroughputModelStat[];
}
-/**
- * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
- * request for a conversation (e.g. a "warm now" button). The warm replays the
- * conversation's existing prefix to refresh the provider cache; it is NEVER
- * persisted and NEVER streamed. Pass the SAME `model`/`cwd` the conversation
- * chats with so the prefix is byte-identical to a real turn (that's the cache hit).
- */
-export interface WarmRequest {
- readonly conversationId: string;
- readonly model?: string; // `<credentialName>/<model>`; omit = server default
- readonly cwd?: string;
-}
-
-/**
- * Response body for `POST /chat/warm` (HTTP 200). The warm's usage — never folded
- * into the conversation's real usage. A client surfaces `cachePct` as the "last
- * warming" cache-hit indicator. A 409 (currently generating) returns `{ error }` instead.
- */
-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%**; it drops toward 0 as the cache
- * expires/busts. This is the warming HEALTH signal — headline it for "Warm now".
- */
- readonly expectedCacheRate: number;
-}
-
// ─── Per-conversation working directory (cwd) ─────────────────────────────────
/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
@@ -250,6 +274,28 @@ export interface SetCwdRequest {
readonly cwd: string;
}
+// ─── Conversation close (explicit tab close) ──────────────────────────────────
+
+/**
+ * Response of `POST /conversations/:id/close` (no request body).
+ *
+ * The EXPLICIT "the user closed this conversation's tab" affordance — distinct
+ * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch
+ * the turn or the warming schedule. Closing:
+ * 1. aborts any in-flight turn (the kernel stops at the next event boundary,
+ * partial messages are persisted, and the turn SEALS normally with
+ * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and
+ * 2. stops + disables cache-warming for the conversation (persisted OFF, so a
+ * reopened conversation stays opt-in).
+ * Idempotent: closing an idle or unknown conversation succeeds with
+ * `abortedTurn: false`.
+ */
+export interface CloseConversationResponse {
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
+}
+
// ─── Per-conversation LSP status ──────────────────────────────────────────────
/** The connection state of a single language server for a workspace. */
@@ -280,15 +326,71 @@ export interface LspStatusResponse {
readonly servers: readonly LspServerInfo[];
}
+/**
+ * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
+ * request for a conversation (e.g. a frontend "warm now" button, or fast tests
+ * that don't want to wait for the automatic warming timer).
+ *
+ * The warm replays the conversation's existing prefix to the provider to refresh
+ * its prompt cache; it is NEVER persisted and NEVER streamed (no `AgentEvent`s).
+ * Pass the same `model`/`cwd` the conversation chats with so the warm request's
+ * prefix is byte-identical to a real turn (which is what makes the cache hit).
+ */
+export interface WarmRequest {
+ /** The conversation whose prompt cache to warm. */
+ readonly conversationId: string;
+
+ /**
+ * The model name in `<credentialName>/<model>` form the conversation uses, so
+ * the warm resolves the same provider + prefix. Omit to use the server default.
+ */
+ readonly model?: string;
+
+ /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
+ readonly cwd?: string;
+}
+
+/**
+ * Response body for `POST /chat/warm` (HTTP 200). The warm request's usage —
+ * never folded into the conversation's real usage. A client surfaces `cachePct`
+ * as the "last warming" cache-hit indicator.
+ *
+ * When warming cannot run because the conversation is currently generating, the
+ * server responds `409` with `{ error }` instead of this body.
+ */
+export interface WarmResponse {
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens: number;
+ readonly cacheWriteTokens: number;
+ /**
+ * **Cache rate** — what fraction of THIS request's prompt was served from cache:
+ * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`).
+ * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].)
+ */
+ readonly cachePct: number;
+ /**
+ * **Expected cache (retention)** — of the cacheable prefix this warm touched, how
+ * much was still warm and read back vs. had to be (re)written:
+ * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the
+ * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still
+ * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite
+ * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's
+ * tiny fresh probe makes ~equal, but which on a real turn reflects new content).
+ */
+ readonly expectedCacheRate: number;
+}
+
// ─── WebSocket chat ops ───────────────────────────────────────────────────────
// The persistent WS connection multiplexes chat ops (below) with surface ops
-// (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they
-// never collide with surface ones.
+// (`@dispatch/ui-contract`). The unified unions at the bottom compose both. Chat
+// `type`s are namespaced (`chat.*`) so they never collide with surface ones.
/**
- * Client → server: start or continue a turn over the WS connection. Same fields
- * as the HTTP `ChatRequest`; omit `conversationId` to start fresh — the resolved
- * id arrives on the streamed `AgentEvent`s (each carries `conversationId`).
+ * Client → server: start or continue a turn over the WS connection. Carries the
+ * same fields as the HTTP `ChatRequest` (so one shape drives both transports);
+ * omit `conversationId` to start fresh — the resolved id arrives on the streamed
+ * `AgentEvent`s (each carries `conversationId`).
*/
export interface ChatSendMessage extends ChatRequest {
readonly type: "chat.send";
@@ -296,8 +398,9 @@ export interface ChatSendMessage extends ChatRequest {
/**
* Server → client: one `AgentEvent` from an in-flight turn (text-delta,
- * tool-call, usage, done, turn-sealed, …). Fold these into the transcript
- * exactly as the HTTP NDJSON stream — same events, different carrier.
+ * tool-call, usage, done, turn-sealed, …). The client folds these into its
+ * transcript exactly as it folds the HTTP NDJSON stream — same events, different
+ * carrier.
*/
export interface ChatDeltaMessage {
readonly type: "chat.delta";
@@ -316,13 +419,20 @@ export interface ChatErrorMessage {
}
/**
- * Client → server: start WATCHING a conversation's live turn events WITHOUT sending.
- * On subscribe the server REPLAYS the current in-flight turn so far (from its
- * `turn-start`) as `chat.delta`, then streams live; nothing replayed if idle (rely
- * on `GET /conversations/:id` history). Infer "generating" from a replayed
- * `turn-start` with no matching `done`/`turn-sealed` yet. `chat.send` already
- * auto-subscribes the sender, so this is for conversations you VIEW but didn't send to
- * (a 2nd device, or a reloaded/reconnected client). Idempotent per (connection, id).
+ * Client → server: start WATCHING a conversation's live turn events WITHOUT
+ * sending a message. This is what makes a turn viewable independently of who
+ * started it — a second device (multi-client handoff) or a client that reloaded
+ * mid-turn subscribes to receive the in-flight turn.
+ *
+ * On subscribe the server replays the CURRENT in-flight turn's events so far as
+ * `chat.delta` messages (so a late-joiner sees the whole running turn from its
+ * `turn-start`), then streams subsequent live events. If no turn is in-flight,
+ * nothing is replayed (the client relies on `GET /conversations/:id` history).
+ * A client infers "generating" from a replayed `turn-start` with no matching
+ * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`.
+ *
+ * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs
+ * `chat.subscribe` for conversations it is viewing but did not send to.
*/
export interface ChatSubscribeMessage {
readonly type: "chat.subscribe";
@@ -331,22 +441,28 @@ export interface ChatSubscribeMessage {
/**
* Client → server: stop watching a conversation's turn events on this connection.
- * Does NOT stop/affect the turn (it runs to completion regardless of subscribers).
- * Socket close drops all of a connection's subscriptions the same way — again
- * WITHOUT aborting any in-flight turn.
+ * Does NOT stop or affect the turn itself (the turn runs to completion regardless
+ * of subscribers). The server also drops all of a connection's subscriptions when
+ * the socket closes — again WITHOUT aborting any in-flight turn.
*/
export interface ChatUnsubscribeMessage {
readonly type: "chat.unsubscribe";
readonly conversationId: string;
}
-/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */
+/**
+ * Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A server discriminates on `type`.
+ */
export type WsClientMessage =
| SurfaceClientMessage
| ChatSendMessage
| ChatSubscribeMessage
| ChatUnsubscribeMessage;
-/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */
+/**
+ * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A client discriminates on `type`.
+ */
export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage;
```
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
index 00d354f..d751af8 100644
--- a/.dispatch/ui-contract.reference.md
+++ b/.dispatch/ui-contract.reference.md
@@ -5,29 +5,49 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this
> file is for READING only.
>
-> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes.
+> **Orchestrator:** this is a SNAPSHOT of `[email protected]` — regenerate it whenever
+> `ui-contract` changes.
+>
+> **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
+> conservative always-send-conversationId policy remains correct for both). Emitted today:
+> `loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Also (CR-4d, no shape
+> change): the initial `surface` reply to a conversation-scoped subscribe ECHOES `conversationId`
+> as documented (was already on backend HEAD; verify with a freshly-booted backend).
>
> **2026-06 delta (cache-warming handoff):** adds the `NumberField` variant (`kind:"number"`) to
> the `SurfaceField` union, and an OPTIONAL `conversationId?` to `SubscribeMessage` /
> `UnsubscribeMessage` / `InvokeMessage` / `SurfaceMessage` / `SurfaceUpdate` so a surface can be
> CONVERSATION-SCOPED (state differs per conversation, e.g. `cache-warming`) vs GLOBAL (one state for
> all, e.g. `loaded-extensions`). All additive / backward-compatible: a global surface omits
-> `conversationId` and behaves exactly as before. (Backend left the package version at `0.1.0`.)
+> `conversationId` and behaves exactly as before.
```ts
/**
* UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
*
* A SURFACE is a "data transportation surface": a typed description of what data an
- * extension exposes, its semantics, and the actions that can act on it — NOT UI.
- * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile).
- * Types-only, zero runtime, zero `@dispatch/*` deps.
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI. It
+ * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering-
+ * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface
+ * in its own idiom, so swapping or adding a client is a zero-backend-change event.
+ *
+ * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies,
+ * so a separate client repo can depend on JUST this contract.
*/
-/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */
+/**
+ * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS
+ * instruction. A client maps a region to its own idiom; an unknown region falls back
+ * to the client's default placement. Deliberately left open (a `string`).
+ */
export type Region = string;
-/** A typed reference to a backend action a field can invoke (client posts payload back). */
+/**
+ * A typed reference to a backend action a field can invoke. The client posts it back
+ * (with a payload); the surface id comes from context.
+ */
export interface ActionRef {
readonly actionId: string;
}
@@ -38,7 +58,10 @@ export interface SurfaceOption {
readonly label: string;
}
-/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */
+/**
+ * A field within a surface — a SEMANTIC value, not a widget. `kind` is the
+ * discriminant a client switches on to pick a renderer.
+ */
export type SurfaceField =
| ToggleField
| ProgressField
@@ -56,7 +79,7 @@ export interface ToggleField {
readonly action: ActionRef;
}
-/** A bounded ratio in [0, 1] with a label. Read-only. */
+/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */
export interface ProgressField {
readonly kind: "progress";
readonly label: string;
@@ -83,7 +106,7 @@ export interface StatField {
* A settable numeric value plus the action that sets it — the free-value
* counterpart to `selector` (which is a fixed enum). Optional `min`/`max`/`step`
* are SEMANTIC bounds a client may use to validate/step input; `unit` is a
- * display hint (e.g. "ms", "s"). The client posts the new number as the action
+ * display hint (e.g. "ms", "min"). The client posts the new number as the action
* payload. Unlike `progress`/`stat` (read-only), this field is interactive.
*/
export interface NumberField {
@@ -105,8 +128,10 @@ export interface ButtonField {
}
/**
- * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a
- * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP.
+ * The escape hatch: data that fits no semantic field kind. Carries an opaque
+ * `payload` + a `rendererId`; clients WITH a renderer for that id show it, others
+ * GRACEFULLY SKIP. Keep rare — and the owning extension should export a typed
+ * payload type so its bespoke renderer narrows `payload` via a typed symbol.
*/
export interface CustomField {
readonly kind: "custom";
@@ -114,7 +139,10 @@ export interface CustomField {
readonly payload: unknown;
}
-/** A surface: an ordered set of fields mounted in a region, with a title. */
+/**
+ * A surface: an ordered set of fields mounted in a region, with a title. The atomic
+ * unit a backend extension contributes and a client renders.
+ */
export interface SurfaceSpec {
readonly id: string;
readonly region: Region;
@@ -122,20 +150,33 @@ export interface SurfaceSpec {
readonly fields: readonly SurfaceField[];
}
-/** A surface-catalog entry — discovery metadata only (no field data). */
+/**
+ * A surface-catalog entry — discovery metadata only (no field data).
+ */
export interface SurfaceCatalogEntry {
readonly id: string;
readonly region: Region;
readonly title: string;
+ /**
+ * Whether the surface's spec/values differ per conversation ("conversation")
+ * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces
+ * on a conversation switch (they ignore `conversationId`). Optional + additive:
+ * when absent, a client should assume conversation-scoped (the conservative
+ * "always send the focused conversationId" policy still works for both).
+ */
+ readonly scope?: "global" | "conversation";
}
/** The surface catalog: the list of available surfaces a client can choose to show. */
export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
/**
- * A live update for a subscribed surface. v1 carries the full new spec.
- * `conversationId` is present only for a CONVERSATION-SCOPED surface (tells the
- * client which conversation this update is for); a global surface omits it.
+ * A live update for a subscribed surface (pushed over the WS channel). v1 carries
+ * the full new spec (the simplest "patch").
+ *
+ * `conversationId` is present only for a CONVERSATION-SCOPED surface (one whose
+ * spec/values differ per conversation, e.g. cache-warming controls): it tells the
+ * client which conversation this update pertains to. A global surface omits it.
*/
export interface SurfaceUpdate {
readonly surfaceId: string;
@@ -143,14 +184,18 @@ export interface SurfaceUpdate {
readonly conversationId?: string;
}
-// ── Surface WebSocket protocol (slice 1: surfaces only) ──────────────────────
+// ── Surface WebSocket protocol ────────────────────────────────────────────────
/** A client → server message on the surface channel. */
export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
/**
- * Begin receiving live updates for a surface. For a CONVERSATION-SCOPED surface,
- * include the `conversationId` whose state you want; omit it for a global surface.
+ * Begin receiving live updates for a surface (server replies with `surface`, then `update`s).
+ *
+ * For a CONVERSATION-SCOPED surface, include the `conversationId` whose state you
+ * want — the server resolves the spec for that conversation and pushes its updates.
+ * Omit it for a global surface (or to view a conversation-scoped surface with no
+ * conversation in focus → the surface decides its default/empty state).
*/
export interface SubscribeMessage {
readonly type: "subscribe";
@@ -208,7 +253,7 @@ export interface SurfaceUpdateMessage {
readonly update: SurfaceUpdate;
}
-/** A surface-scoped error. */
+/** A surface-scoped error (e.g. unknown surface id, invoke failed). */
export interface SurfaceErrorMessage {
readonly type: "error";
readonly surfaceId?: string;