summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
commiteff289b2b4cc41db706f3c3274a089d46012be87 (patch)
tree3f5de169708f89a38f913954da2a351cb24575da /.dispatch
parentc95cc77b658edd072785d3ac93856de3ab9ad2ec (diff)
downloaddispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.tar.gz
dispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.zip
chore: regenerate contract mirrors, bump deps, update docs/notes/scripts
- Regenerate .dispatch/{wire,transport-contract}.reference.md mirrors - Bump deps (package.json, bun.lock) - Update AGENTS.md, GLOSSARY.md, README.md, ROADMAP.md - Add workspaces backend-handoff exchange + notes/assumptions-log.md - Add scripts/fix-dist-perms.sh (root-owned dist/ from Docker build) - Add scripts/live-probe-provider-retry.ts
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/transport-contract.reference.md243
-rw-r--r--.dispatch/wire.reference.md213
2 files changed, 343 insertions, 113 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 02b48a0..8c6023a 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,3 +1,29 @@
+# `@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). Regenerate whenever
+> it changes.
+>
+> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds
+> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint
+> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns
+> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`,
+> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`,
+> and optional `configSource`. Also adds the previously-missing `configSource` field to
+> `LspServerInfo`. See `frontend-mcp-status-handoff.md`.
+>
+> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds
+> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and
+> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and
+> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation
+> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`,
+> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See
+> `frontend-system-prompt-handoff.md`.
+
/**
* Transport contract — the typed description of Dispatch's client–server API
* (HTTP + WebSocket).
@@ -28,6 +54,8 @@ import type {
ReasoningEffort,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
export type {
@@ -40,6 +68,8 @@ export type {
StepMetrics,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
/**
@@ -80,6 +110,13 @@ export interface ChatRequest {
* unrecognized value → HTTP 400 `{ error }`.
*/
readonly reasoningEffort?: ReasoningEffort;
+
+ /**
+ * The workspace to assign this conversation to. Omit for `"default"`.
+ * If the workspace doesn't exist yet, it is auto-created (title = id,
+ * defaultCwd = null).
+ */
+ readonly workspaceId?: string;
}
/**
@@ -170,6 +207,14 @@ export interface ConversationMetricsResponse {
readonly turns: readonly TurnMetrics[];
}
+export interface ConversationStatusResponse {
+ readonly conversationId: string;
+ /** True if the orchestrator has an in-memory active turn for this conversation. */
+ readonly isActive: boolean;
+ /** The persisted lifecycle status from the conversation store. */
+ readonly status: ConversationStatus;
+}
+
/** The aggregation window for `GET /metrics/throughput`. */
export type ThroughputPeriod = "day" | "week" | "month";
@@ -221,9 +266,19 @@ export interface CwdResponse {
readonly cwd: string | null;
}
-/** Body of `PUT /conversations/:id/cwd`. */
+/**
+ * Body of `PUT /conversations/:id/cwd`.
+ *
+ * When `workspaceId` is provided, the conversation is assigned to that
+ * workspace BEFORE the cwd is persisted — so a subsequent
+ * `GET /conversations/:id/lsp` resolves a relative cwd against the
+ * workspace's `defaultCwd` (not the server default). Omit for unchanged
+ * workspace assignment (the conversation keeps its current workspace, or
+ * `"default"` if none).
+ */
export interface SetCwdRequest {
readonly cwd: string;
+ readonly workspaceId?: string;
}
// ─── Per-conversation reasoning effort ────────────────────────────────────────
@@ -248,6 +303,29 @@ export interface SetReasoningEffortRequest {
readonly reasoningEffort: ReasoningEffort;
}
+// ─── Per-conversation model persistence ───────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/model`. `model` is the persisted model
+ * name in `<credentialName>/<model>` form, or null when never set (the server
+ * then resolves turns using the default provider + model).
+ */
+export interface ModelResponse {
+ readonly conversationId: string;
+ readonly model: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/model` — persists the conversation's sticky
+ * model selection (used for every later turn that does not carry a per-turn
+ * `ChatRequest.model` override). Pass `null` to clear the persisted selection.
+ * An unrecognized model name is not validated here (the provider resolves it
+ * at turn time; an unknown model → turn error, not a 400).
+ */
+export interface SetModelRequest {
+ readonly model: string | null;
+}
+
// ─── Conversation close (explicit tab close) ──────────────────────────────────
/**
@@ -270,6 +348,57 @@ export interface CloseConversationResponse {
readonly abortedTurn: boolean;
}
+// ─── System prompt template ───────────────────────────────────────────────────
+
+/**
+ * Response of `GET /system-prompt` — the current global system prompt template.
+ *
+ * The template is a text string with variable placeholders (`[type:name]`) and
+ * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first
+ * turn or compaction), variables are resolved against the conversation's cwd
+ * and system state. The resolved system prompt is persisted per conversation
+ * and reused on all subsequent turns (cache-safe — no per-turn reconstruction).
+ */
+export interface SystemPromptTemplateResponse {
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
+}
+
+/**
+ * Body of `PUT /system-prompt` — set the global system prompt template.
+ *
+ * Changing the template does NOT affect existing conversations until they are
+ * compacted (the persisted resolved system prompt is stable). New
+ * conversations use the new template on their first turn.
+ */
+export interface SetSystemPromptTemplateRequest {
+ readonly template: string;
+}
+
+/**
+ * One available variable for the system prompt template, as reported by
+ * `GET /system-prompt/variables` so the frontend can render the variable
+ * selector buttons.
+ */
+export interface SystemPromptVariable {
+ /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
+ readonly type: string;
+ /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
+ readonly name: string;
+ /** Human-readable description of what the variable resolves to. */
+ readonly description: string;
+ /**
+ * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
+ * any file path). The frontend should allow free-text input for the name.
+ */
+ readonly dynamic?: boolean;
+}
+
+/** Response of `GET /system-prompt/variables`. */
+export interface SystemPromptVariablesResponse {
+ readonly variables: readonly SystemPromptVariable[];
+}
+
// ─── Message queue (steering) ─────────────────────────────────────────────────
/**
@@ -289,6 +418,11 @@ export interface CloseConversationResponse {
*/
export interface QueueRequest {
readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
}
/**
@@ -324,17 +458,60 @@ export interface LspServerInfo {
readonly state: LspServerState;
/** Present only when `state === "error"`: a short human-readable reason. */
readonly error?: string;
+ /**
+ * Which config source this server was resolved from: `".dispatch/lsp.json"`,
+ * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted
+ * when not yet resolved. Surfaces config-shadow debugging to the status caller
+ * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`).
+ */
+ readonly configSource?: string;
}
/** Response of `GET /conversations/:id/lsp`. */
export interface LspStatusResponse {
readonly conversationId: string;
- /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */
+ /**
+ * The resolved working directory the LSP connects on, or `null` when no
+ * cwd has been set for the conversation (then `servers` is empty). When
+ * non-null, this is the effective cwd — a relative persisted cwd resolved
+ * against the conversation's workspace `defaultCwd`.
+ */
readonly cwd: string | null;
/** The language servers configured for `cwd` and their live state. */
readonly servers: readonly LspServerInfo[];
}
+// ─── MCP status ──────────────────────────────────────────────────────
+
+export type McpServerState = "connecting" | "connected" | "error" | "disconnected";
+
+/** One MCP server's status as reported to the frontend. */
+export interface McpServerInfo {
+ /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */
+ readonly id: string;
+ /** Current connection state. */
+ readonly state: McpServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source this server was resolved from. */
+ readonly configSource?: string;
+}
+
+/** Response of `GET /conversations/:id/mcp`. */
+export interface McpStatusResponse {
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the MCP servers are configured for, or
+ * `null` when no cwd has been set for the conversation (then `servers` is
+ * empty). Mirrors the LSP status endpoint behavior.
+ */
+ readonly cwd: string | null;
+ /** The MCP servers configured for `cwd` and their live state. */
+ readonly servers: readonly McpServerInfo[];
+}
+
/**
* Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
* request for a conversation (e.g. a frontend "warm now" button, or fast tests
@@ -474,6 +651,11 @@ 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;
}
/**
@@ -509,6 +691,12 @@ export type WsServerMessage =
export interface ConversationOpenMessage {
readonly type: "conversation.open";
readonly conversationId: string;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
@@ -520,6 +708,12 @@ export interface ConversationStatusChangedMessage {
readonly type: "conversation.statusChanged";
readonly conversationId: string;
readonly status: ConversationStatus;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
@@ -606,3 +800,48 @@ export interface CompactPercentResponse {
export interface SetCompactPercentRequest {
readonly threshold: number;
}
+
+// ─── Workspaces ───────────────────────────────────────────────────────────────
+
+/**
+ * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All
+ * fields are optional and only applied when the workspace is first created;
+ * an existing workspace is returned as-is.
+ */
+export interface EnsureWorkspaceRequest {
+ /** Display title. Default: the workspace id. Only used on create. */
+ readonly title?: string;
+ /** Default cwd. Default: null (inherit server default). Only used on create. */
+ readonly defaultCwd?: string | null;
+}
+
+/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */
+export interface WorkspaceResponse extends Workspace {}
+
+/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */
+export interface WorkspaceListResponse {
+ readonly workspaces: readonly WorkspaceEntry[];
+}
+
+/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
+export interface SetWorkspaceTitleRequest {
+ readonly title: string;
+}
+
+/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
+export interface SetWorkspaceDefaultCwdRequest {
+ readonly defaultCwd: string | null;
+}
+
+/**
+ * Response of `DELETE /workspaces/:id`. All conversations in the workspace
+ * are closed (status → "closed") and reassigned to "default", then the
+ * workspace entity is deleted. `"default"` is non-deletable (HTTP 409).
+ */
+export interface DeleteWorkspaceResponse {
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
+}
+```
+
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index 44b0fe7..b7003ec 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,98 +4,13 @@
> 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]` (compaction). Regenerate
-> whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces). Regenerate whenever `@dispatch/wire` changes.
>
-> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):**
-> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`,
-> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it
-> becomes the conversation's first system message after compaction).
->
-> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):**
-> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle
-> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` =
-> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are
-> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on
-> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged`
-> (see `[email protected]`).
->
-> **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):**
-> adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt),
-> returned by `GET /conversations` (the list endpoint, see `[email protected]`).
->
-> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):**
-> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING,
-> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`,
-> see `[email protected]`); it is delivered mid-turn as **steering** — injected at the next
-> tool-result boundary so the model sees it alongside the tool results and can adjust course. If the
-> turn ends with a non-empty queue (no tool call fired), the queue is carried into a NEW turn as its
-> opening prompt (no `steering` event — the new turn's `user-message` covers it).
->
-> Adds:
-> - **`QueuedMessage`** (`{ id, text, queuedAt }`) — a message held in the queue (stable id for UI
-> keying + dedup).
-> - **`QueuePayload`** (`{ messages: QueuedMessage[] }`) — the payload of the message-queue
-> extension's per-conversation `custom` surface field (`rendererId: "message-queue"`). Carried on
-> the SURFACE channel (NOT the chat stream) — the queue is control/state. Empty `messages` = empty
-> queue. See `transport-contract.reference.md` for the surface + the enqueue op.
-> - **`TurnSteeringEvent`** (`{ type: "steering"; conversationId; turnId; text }`) — a NEW
-> `AgentEvent` union member, emitted on the chat stream when the kernel drains a non-empty queue
-> at a tool-result boundary. Render `text` as a USER bubble in the transcript (positioned after
-> the tool-result it followed); the queue surface separately clears on drain. One event per drain;
-> `text` is the combined text of all drained messages. Late-join safe (buffered into the in-flight
-> turn's event buffer, mirroring `user-message`). Carry-to-new-turn does NOT emit `steering`.
-> ADDITIVE to the union — if you have an exhaustive `AgentEvent` switch, add a `steering` case.
->
-> **2026-06-12 delta (reasoning-effort handoff — package bumped `0.6.1` → `0.7.0`, ADDITIVE):**
-> adds the **`ReasoningEffort`** type — the per-request thinking-depth ladder
-> `"low" | "medium" | "high" | "xhigh" | "max"`. Provider-agnostic; the Anthropic provider maps
-> levels to extended-thinking token budgets (low 4096 · medium 10240 · high 16384 · xhigh 32768 ·
-> max 65536); providers without a thinking knob ignore it. Resolution is SERVER-owned (do not
-> re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value
-> (`GET`/`PUT /conversations/:id/reasoning-effort`, see `[email protected]`) → default
-> `"high"`. Higher levels mean longer runs of `reasoning-delta` events before the first text delta.
-> See the `ReasoningEffort` definition below.
->
-> **2026-06-12 delta (CR-5 history windowing — package bumped `0.6.0` → `0.6.1`, DOC-ONLY):** the
-> per-conversation `seq` numbering is now a WRITTEN CONTRACTUAL GUARANTEE on `StoredChunk`:
-> **1-based, monotonic, gap-free** — a conversation's first chunk is always `seq === 1` and
-> numbering never skips. A client holding only a windowed suffix of the log derives "older chunks
-> exist server-side" purely from `oldestLoaded.seq > 1` (no `earliestSeq`/`hasOlder` field exists).
->
-> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a
-> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`)
-> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of
-> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join
-> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt
-> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that
-> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below.
->
-> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL
-> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the
-> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate
-> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current
-> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field
-> doc-comments on `TurnMetrics`/`TurnDoneEvent` below.
->
-> **0.3.0 changes (token + timing metrics):**
-> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted):
-> `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW
-> `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step
-> generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the
-> runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent`
-> gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across
-> steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time).
-> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics`
-> — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by
-> `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME
-> `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it
-> for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire.
-> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL
-> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`.
-> Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read
-> `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is
-> UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`).
+> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds
+> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required
+> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A
+> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that
+> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`.
```ts
/**
@@ -299,8 +214,8 @@ export interface StepMetrics {
* Durable per-turn metrics for a completed (sealed) turn — the persisted,
* replayable counterpart of the live `done` event's aggregate `usage` +
* `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
- * steps; `steps` carries each step's `StepMetrics` in step order. Stored by
- * `conversation-store` keyed by `turnId` and served by
+ * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per
+ * turn by `conversation-store` (returned in turn-append order) and served by
* `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
* on every `AgentEvent`, the join key to the live stream.)
*/
@@ -373,6 +288,7 @@ export type AgentEvent =
| TurnUsageEvent
| TurnStepCompleteEvent
| TurnErrorEvent
+ | TurnProviderRetryEvent
| TurnDoneEvent
| TurnSealedEvent
| TurnSteeringEvent;
@@ -393,13 +309,15 @@ export interface TurnStartEvent {
/**
* The user prompt that opened this turn, surfaced INTO the turn's outward event
- * stream so a WATCHER (subscribed but not the sender) can render the prompt
- * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE
- * as the FIRST event of the turn (before `turn-start`); buffered + replayed to
- * every subscriber (live + late-join). The sender echoes its own prompt
- * optimistically, so DE-DUP against that (by text); a pure watcher renders it
- * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it
- * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.)
+ * stream. The user message is persisted only when the turn seals (atomically with
+ * the assistant reply), so without this event a client that is merely WATCHING a
+ * conversation (subscribed but not the sender) has no source for the prompt text
+ * mid-turn — it would see the streaming reply with no preceding user bubble until
+ * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it
+ * is buffered and replayed to every subscriber — live and late-join — exactly like
+ * the rest of the turn. The sender already echoes its own prompt optimistically, so
+ * a consumer should de-dup against that (e.g. by text); a pure watcher renders it
+ * directly. Carries the raw prompt `text` (the same text passed to the provider).
*/
export interface TurnInputEvent {
readonly type: "user-message";
@@ -527,6 +445,31 @@ export interface TurnErrorEvent {
readonly code?: string;
}
+/**
+ * A retryable provider error is being retried with backoff. Emitted once per
+ * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded —
+ * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT
+ * persisted into the model's message history (it never pollutes the prompt).
+ *
+ * When the retry budget is exhausted, the existing `error` event is emitted and
+ * the turn seals — so the final failure is still a persisted error. `attempt` is
+ * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep
+ * before that retry fires.
+ */
+export interface TurnProviderRetryEvent {
+ readonly type: "provider-retry";
+ readonly conversationId: string;
+ readonly turnId: string;
+ /** 0-based: this is the Nth retry about to happen. */
+ readonly attempt: number;
+ /** ms the client should expect to wait before the retry fires. */
+ readonly delayMs: number;
+ /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */
+ readonly message: string;
+ /** The HTTP code when known (e.g. "429"). */
+ readonly code?: string;
+}
+
/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
readonly type: "done";
@@ -545,11 +488,11 @@ export interface TurnDoneEvent {
*/
readonly usage?: Usage;
/**
- * **Context size** — tokens the conversation occupies right now: the turn's
- * FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM
- * round-trip plus that round-trip's output). This is the "tokens in context"
- * figure a client renders as the chat's current context usage, and a client
- * treats the LATEST turn's value as the live total.
+ * **Context size** — the number of tokens the conversation now occupies: this
+ * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full
+ * prompt sent into the last LLM round-trip plus that round-trip's output). This
+ * is the "tokens in context" figure a client renders as the chat's current
+ * context usage, and a client treats the LATEST turn's value as the live total.
*
* Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
* `inputTokens`, which overcounts a multi-step / tool-calling turn because every
@@ -598,10 +541,11 @@ export interface TurnSteeringEvent {
// ─── Conversation metadata ───────────────────────────────────────────────────
/**
- * The per-conversation lifecycle status. `active` = a turn is generating;
- * `idle` = exists, not generating; `closed` = dismissed (hidden from the tab
- * bar, not deleted). Transitions are backend-owned and pushed via the
- * `conversation.statusChanged` WS message (see `transport-contract`).
+ * The lifecycle status of a conversation, used for tab persistence across
+ * devices. `active` = an agent is currently generating; `idle` = exists but not
+ * generating; `closed` = user dismissed the tab (hidden from the tab bar, not
+ * deleted). New conversations start as `idle`; transitions to `active` on
+ * turn-start, back to `idle` on turn done/error, and to `closed` on user close.
*/
export type ConversationStatus = "active" | "idle" | "closed";
@@ -609,7 +553,8 @@ export type ConversationStatus = "active" | "idle" | "closed";
* Metadata for a conversation, returned by `GET /conversations` (the list
* endpoint). The title defaults to the first user message (truncated) and can
* be set via `PUT /conversations/:id/title`. `createdAt` is set on first write;
- * `lastActivityAt` is updated on every append.
+ * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle
+ * for cross-device persistence.
*/
export interface ConversationMeta {
readonly id: string;
@@ -617,7 +562,17 @@ export interface ConversationMeta {
readonly lastActivityAt: number;
readonly title: string;
readonly status: ConversationStatus;
- /** Points to the archive conversation with full pre-compaction history. */
+ /**
+ * The workspace this conversation belongs to. Always present; reads as
+ * `"default"` for legacy conversations that were never explicitly assigned.
+ * Conversations created with no `workspaceId` default to `"default"`.
+ */
+ readonly workspaceId: string;
+ /**
+ * Set on a compacted conversation: points to the archive conversation ID
+ * that holds the full pre-compaction history. Absent on conversations
+ * that have never been compacted.
+ */
readonly compactedFrom?: string;
}
@@ -627,6 +582,8 @@ export interface ConversationMeta {
* Result of a compaction operation. `summary` is the text the model produced;
* `messagesKept` is how many recent messages were retained after the summary;
* `messagesSummarized` is how many old messages were replaced by the summary.
+ * `newConversationId` is the ID of the new conversation that holds the full
+ * pre-compaction history (non-destructive — the original history is preserved).
*/
export interface CompactionResult {
readonly summary: string;
@@ -634,4 +591,38 @@ export interface CompactionResult {
readonly messagesSummarized: number;
readonly messagesKept: number;
}
+
+// ─── Workspaces ──────────────────────────────────────────────────────────────
+
+/**
+ * A named, URL-driven grouping of conversations that owns a default cwd.
+ * Every conversation belongs to exactly one workspace; conversations that
+ * haven't set their own per-conversation cwd inherit `defaultCwd`.
+ *
+ * Workspaces are backend-owned (so cross-device just works): the workspace
+ * entity and each conversation's `workspaceId` live server-side. The
+ * `"default"` workspace is always present and non-deletable; conversations
+ * created with no `workspaceId` are assigned to `"default"`.
+ */
+export interface Workspace {
+ /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
+ readonly id: string;
+ /** Display title (editable). Defaults to `id` on creation. */
+ readonly title: string;
+ /** The workspace's default cwd, or `null` (fall through to server default). */
+ readonly defaultCwd: string | null;
+ /** Epoch-ms when the workspace was first created. */
+ readonly createdAt: number;
+ /** Epoch-ms of the most recent conversation activity in this workspace. */
+ readonly lastActivityAt: number;
+}
+
+/**
+ * A workspace entry in the list response (`GET /workspaces`) — a `Workspace`
+ * plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}
```