summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md243
-rw-r--r--.dispatch/wire.reference.md213
-rw-r--r--AGENTS.md10
-rw-r--r--GLOSSARY.md1
-rw-r--r--README.md16
-rw-r--r--ROADMAP.md1
-rw-r--r--backend-handoff-workspaces-reply.md216
-rw-r--r--backend-handoff-workspaces.md157
-rw-r--r--backend-handoff.md279
-rw-r--r--bun.lock20
-rw-r--r--notes/assumptions-log.md58
-rw-r--r--package.json10
-rwxr-xr-xscripts/fix-dist-perms.sh25
-rw-r--r--scripts/live-probe-provider-retry.ts188
14 files changed, 1284 insertions, 153 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;
+}
```
diff --git a/AGENTS.md b/AGENTS.md
index 4c9f3dd..9a62d02 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,7 @@
> Loaded every session — the single source of truth for working in this repo: the
> build constitution (code rules) + the workflow. Non-obvious, project-specific rules
> only — if a fresh frontier model could infer it from the code, it is NOT here (P6).
-> Full design + rationale: `../arch-rewrite/notes/frontend-design.md` (and the
+> Full design + rationale: `../dispatch-backend/notes/frontend-design.md` (and the
> backend's `notes/restructure-plan.md` §1 for P1–P8).
>
> **You are the single agent for this repo.** You plan, author the cross-unit
@@ -13,7 +13,7 @@
## What this is
The **web frontend** for Dispatch — a SEPARATE repo from the backend
-(`../arch-rewrite`). It is a **thin shell + pure feature libraries + a surface
+(`../dispatch-backend`). It is a **thin shell + pure feature libraries + a surface
host**, NOT a default-SvelteKit ball of mud. It consumes the backend's typed
contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket. The app
is a COMPOSITION of feature modules + surfaces, assembled at the composition root
@@ -37,7 +37,7 @@ src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history
.dispatch/ mirrored backend contracts (*.reference.md) + rules/
reports/ (gitignored)
```
-Backend (SEPARATE repo, contracts only): `../arch-rewrite` — consume
+Backend (SEPARATE repo, contracts only): `../dispatch-backend` — consume
`@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it.
## The non-negotiable rules
@@ -91,7 +91,7 @@ effects backed by a shared global (`fake-indexeddb`, `localStorage`) to catch cr
pollution. After a slice that touches the wire or browser effects, run a LIVE probe (below).
## Backend seam (cross-repo)
-The backend is `../arch-rewrite` (separate repo; `lsp references` does NOT span the
+The backend is `../dispatch-backend` (separate repo; `lsp references` does NOT span the
boundary). You consume `@dispatch/ui-contract` + the wire/transport types as pinned `file:`
deps. **Read the in-repo mirrors `.dispatch/*.reference.md`, never `node_modules/@dispatch/*`**
(they symlink out of the repo); regenerate the relevant mirror whenever a contract changes.
@@ -143,7 +143,7 @@ live view (subscribe/reconnect + the user prompt on the event stream), and the c
show-earlier server backfill; `hasOlder` from the 1-based gap-free seq contract), and the
reasoning-effort selector (Model view, under the provider/model dropdowns; sticky per-conversation
`GET`/`PUT /reasoning-effort`, `null` ⇒ "high (default)"). Plan in
-`../arch-rewrite/notes/frontend-design.md` §10.
+`../dispatch-backend/notes/frontend-design.md` §10.
## Reports
Optionally record a finished milestone in `reports/<name>.md` (gitignored): what you built,
diff --git a/GLOSSARY.md b/GLOSSARY.md
index e350ffa..fe4360c 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -43,3 +43,4 @@
| **chat limit** | The max LOADED chunks per conversation (default 256; persisted at localStorage `dispatch.chatLimit`, settable via the sidebar's Settings view) before the oldest quarter is unloaded. Counts **chunks** (committed + provisional + accumulating). Policy in `core/chunks/trim.ts`. | chunk limit, message limit, history limit |
| **unload** | Drop the oldest COMMITTED chunks from the in-memory transcript (and DOM) past the **chat limit** — in BULK (`ceil(limit/4)` per pass, deferred while the reader is scrolled up), never one-per-delta (old Dispatch's scroll-jump bug). Purely local: the IndexedDB cache and the server keep everything; `TranscriptState.hiddenBeforeSeq` is the watermark. Distinct from the conversation-cache's cross-conversation **eviction**. | evict (reserved for the cross-conversation cache), prune, drop |
| **show earlier** | The affordance at the top of a transcript with unloaded history ("Show earlier messages"): pages one unload-unit back in — local cache first, then the server (CR-5 `?beforeSeq=&limit=`) when the cache doesn't reach far enough back — preserving the reader's scroll position. Offered whenever the loaded window starts above seq 1 (the [email protected] 1-based gap-free seq contract). | load more, pagination |
+| **workspaces** | A URL-driven grouping of conversations that owns a **default cwd**. The root path `/` lists workspaces; `/<id>` opens one (visiting a nonexistent id creates it — create-on-miss). Each conversation belongs to exactly one workspace; the always-present `"default"` workspace is the fallback for unassigned/legacy conversations. Tabs are scoped per workspace (filtered client-side). Backend-owned (`[email protected]` `Workspace`); a conversation's cwd inherits `workspace.defaultCwd` when its own is unset. Distinct from the per-conversation cwd+LSP module (`features/cwd-lsp`, formerly `features/workspace`). | project, space |
diff --git a/README.md b/README.md
index 9586b33..6147ad7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Dispatch Web
-The **web frontend** for [Dispatch](../arch-rewrite) — a separate repo built to the same
+The **web frontend** for [Dispatch](../dispatch-backend) — a separate repo built to the same
methodology (thin shell + pure feature libraries + a backend-driven *surface* host). It consumes
the backend's typed contracts over HTTP + a WebSocket and ships no business logic the backend
doesn't expose.
@@ -17,17 +17,17 @@ doesn't expose.
- [Bun](https://bun.sh) (v1.3+).
- **The backend repo as a sibling directory** — this repo links `@dispatch/ui-contract` from
- `../arch-rewrite` via a `file:` dependency:
+ `../dispatch-backend` via a `file:` dependency:
```
dispatch/
- arch-rewrite/ # the backend (Dispatch server)
+ dispatch-backend/ # the backend (Dispatch server)
dispatch-web/ # this repo
```
-- The **backend server running** for surfaces to appear (see `../arch-rewrite/README.md`).
+- The **backend server running** for surfaces to appear (see `../dispatch-backend/README.md`).
```sh
cd dispatch-web
-bun install # links @dispatch/ui-contract from ../arch-rewrite
+bun install # links @dispatch/ui-contract from ../dispatch-backend
```
---
@@ -36,7 +36,7 @@ bun install # links @dispatch/ui-contract from ../arch-rewrite
```sh
# 1) start the backend (sibling repo) — HTTP :24203 + surface WS :24205
-cd ../arch-rewrite && bun run dev
+cd ../dispatch-backend && bun run dev
# 2) start this dev server — Vite on :24204
cd ../dispatch-web && bun run dev
@@ -46,7 +46,7 @@ Open **http://localhost:24204**. You'll see the surface catalog (e.g. "Loaded Ex
frontend connects to the backend's surface WebSocket at `ws://localhost:24205` (override with
`VITE_WS_URL`).
-> **Tip — run both at once with live reload:** the backend repo ships `../arch-rewrite/bin/up`
+> **Tip — run both at once with live reload:** the backend repo ships `../dispatch-backend/bin/up`
> (also `bun run dev:all` there) which starts the backend (`bun --watch`) + this dev server
> (Vite HMR) together; **Ctrl-C stops both**.
@@ -99,5 +99,5 @@ bun run check # biome (.ts/.js; .svelte correctness is svelte-check's job
## Documentation
-- **Design + plan:** `../arch-rewrite/notes/frontend-design.md`
+- **Design + plan:** `../dispatch-backend/notes/frontend-design.md`
- **Build rules + workflow:** `AGENTS.md` · **Vocabulary:** `GLOSSARY.md`
diff --git a/ROADMAP.md b/ROADMAP.md
index af9f8a4..61b58d3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -19,6 +19,7 @@
- **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag.
- **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations.
- **Conversation compaction** — "Compaction" sidebar view with manual "Compact now" button (`POST /conversations/:id/compact`) + auto-compact threshold input (`GET`/`PUT /conversations/:id/compact-threshold`); `conversation.compacted` WS handler reloads history.
+- **Workspaces** — URL-driven conversation grouping with a backend-owned default cwd (`[email protected]`/`[email protected]`). Routing: `/` lists workspaces (create-on-visit + delete); `/<id>` opens one (tabs scoped to it, existing tabs migrate to `"default"`). New conversations are stamped with the active workspace on `chat.send`/`chat.queue`. Workspace CRUD via `GET`/`PUT`/`DELETE /workspaces`. *Pending: CwdField explicit-vs-inherited display + clear-to-inherit (`DELETE /conversations/:id/cwd`).*
## Next up
diff --git a/backend-handoff-workspaces-reply.md b/backend-handoff-workspaces-reply.md
new file mode 100644
index 0000000..2ddfaf5
--- /dev/null
+++ b/backend-handoff-workspaces-reply.md
@@ -0,0 +1,216 @@
+# Backend handoff — Workspaces (backend → FE) — courier doc
+
+> **From:** arch-rewrite orchestrator · **To:** dispatch-web orchestrator · **Courier:** the user.
+> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes
+> the backend will implement. The FE should re-pin `@dispatch/wire` and
+> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`.
+
+## Version bumps
+
+| Package | From | To | Notes |
+|---|---|---|---|
+| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` |
+| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops |
+| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** |
+
+---
+
+## 1. Final types — `@dispatch/[email protected]`
+
+```ts
+/**
+ * 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`.
+ */
+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 — a `Workspace` plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}
+```
+
+`ConversationMeta` gains a required `workspaceId`:
+
+```ts
+export interface ConversationMeta {
+ readonly id: string;
+ readonly createdAt: number;
+ readonly lastActivityAt: number;
+ readonly title: string;
+ readonly status: ConversationStatus;
+ /** Always present; "default" for legacy/unspecified conversations. */
+ readonly workspaceId: string;
+ readonly compactedFrom?: string;
+}
+```
+
+---
+
+## 2. Final types — `@dispatch/[email protected]`
+
+### Additive fields on existing request types
+
+```ts
+export interface ChatRequest {
+ readonly conversationId?: string;
+ readonly message: string;
+ readonly model?: string;
+ readonly cwd?: string;
+ readonly reasoningEffort?: ReasoningEffort;
+ /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */
+ readonly workspaceId?: string;
+}
+
+export interface QueueRequest {
+ readonly text: string;
+ /** Default "default". Auto-creates if missing. */
+ readonly workspaceId?: string;
+}
+
+export interface ChatQueueMessage {
+ readonly type: "chat.queue";
+ readonly conversationId: string;
+ readonly text: string;
+ /** Default "default". Auto-creates if missing. */
+ readonly workspaceId?: string;
+}
+```
+
+### Workspace endpoint types
+
+```ts
+/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */
+export interface EnsureWorkspaceRequest {
+ /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */
+ 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`. */
+export interface SetWorkspaceTitleRequest {
+ readonly title: string;
+}
+
+/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */
+export interface SetWorkspaceDefaultCwdRequest {
+ readonly defaultCwd: string | null;
+}
+
+/** Response of `DELETE /workspaces/:id`. */
+export interface DeleteWorkspaceResponse {
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
+}
+```
+
+---
+
+## 3. Final endpoint list
+
+| Method & Path | Body | Returns | Notes |
+|---|---|---|---|
+| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. |
+| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. |
+| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. |
+| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). |
+| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. |
+| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. |
+| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. |
+| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). |
+
+### Existing endpoints (semantic note, no type change)
+
+- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default).
+- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd.
+
+---
+
+## 4. cwd resolution (backend-owned)
+
+```
+effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation
+if (effectiveCwd == null) {
+ workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback
+ workspace = conversationStore.getWorkspace(workspaceId)
+ effectiveCwd = workspace?.defaultCwd ?? null
+}
+if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today
+```
+
+- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit).
+- `GET /conversations/:id/lsp` → effective cwd.
+- Turn start (`runTurn` / `warm`) → effective cwd.
+
+---
+
+## 5. `DELETE /workspaces/:id` semantics
+
+1. Close all conversations in that workspace (set `status = "closed"`).
+2. Reassign their `workspaceId` to `"default"` (so no dangling reference).
+3. Delete the workspace entity.
+4. Return `{ workspaceId, closedCount }`.
+5. `DELETE /workspaces/default` → HTTP 409.
+
+Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`).
+
+---
+
+## 6. Workspace lifecycle / auto-creation
+
+- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`).
+- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is.
+- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable.
+- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list.
+- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes.
+- **Compaction:** post-compaction conversations inherit the original's `workspaceId`.
+
+---
+
+## 7. Answers to FE open questions (Q1–Q8)
+
+| # | Decision |
+|---|---|
+| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. |
+| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). |
+| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. |
+| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). |
+| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. |
+| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. |
+| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. |
+| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. |
+
+---
+
+## 8. Gaps resolved (from FE handoff §3)
+
+1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted.
+2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is.
+3. **lastActivityAt on title/default-cwd changes** → no.
+4. **LSP cwd field** → returns effective cwd.
+5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`.
diff --git a/backend-handoff-workspaces.md b/backend-handoff-workspaces.md
new file mode 100644
index 0000000..dc27c0e
--- /dev/null
+++ b/backend-handoff-workspaces.md
@@ -0,0 +1,157 @@
+# Backend handoff — Workspaces (FE → backend) — courier doc
+
+> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
+> Companion to the living `backend-handoff.md` (new open ask, §2). 2026-06-23.
+> `lsp references` does NOT span the two repos, so this is the cross-repo channel.
+> FE is current on `[email protected]` / `[email protected]` / `[email protected]` (686 tests green).
+> **This is a design ask, not a bug report.** Please review, analyze, propose an implementation plan,
+> and surface any gaps / questions back. The FE will adapt to whatever shapes you land on.
+
+---
+
+## 1. What we're building (the FE product behavior)
+
+A **workspace** is a named, URL-driven grouping of **conversations** that owns a **default cwd**.
+Every conversation belongs to exactly one workspace; a workspace's default cwd is used by its
+conversations that haven't set their own per-conversation cwd.
+
+Routing (net-new on the FE — there is no router today):
+- **`/`** (no path) — home: lists all workspaces (title, slug, last activity). The user can **delete** a
+ workspace here.
+- **`/<workspace-id>`** — opens that workspace and loads its tabs.
+- **Visiting `/<id>` when the workspace doesn't exist → it is created at that point** (title defaults to
+ the id; the user can rename the title later). The **id (the URL slug) is immutable**; the **title is
+ display-only and editable**.
+
+Workspaces are **backend-owned** (so cross-device just works): the workspace entity (title, default-cwd),
+and each conversation's `workspaceId`, live server-side. The FE is a thin client over your contracts and
+ships no business logic you don't expose (per the FE's constitution).
+
+### Naming note (FE-internal, NO backend impact)
+The FE already has a `features/workspace` module — but it is the **per-conversation cwd-field + LSP-status**
+module (it consumes `GET`/`PUT /conversations/:id/cwd` + `GET /conversations/:id/lsp`), NOT this concept.
+We are renaming that module to `features/cwd-lsp` so the new feature takes `workspaces`. Flagged only so
+the backend isn't confused by the FE module name; it has **zero contract impact**.
+
+---
+
+## 2. What the FE needs from the backend (the proposed contract surface)
+
+Everything below is the FE's *requirement + proposed shape*. **You own the final shapes** — pick the
+verbs/field-names/type-homes that fit the backend, and ask back where a requirement is unclear or conflicts
+with your design.
+
+### 2.1 Workspace entity + conversation assignment
+- A `Workspace` type (your call: `@dispatch/wire` alongside `ConversationMeta`, or `@dispatch/transport-contract`
+ with the endpoints): `{ id: string (the URL slug, immutable), title: string (defaults to id on creation,
+ editable), defaultCwd: string | null, createdAt: number, lastActivityAt: number }`.
+- `ConversationMeta` gains an additive **`workspaceId: string`**. Conversations created with no workspace
+ ⇒ `"default"` (the fallback). Legacy conversations (no `workspaceId` persisted) should be treated as
+ `"default"` — ideally **no backfill needed**; `ConversationMeta.workspaceId` reads as `"default"` for them.
+
+### 2.2 Conversation creation carries the workspace
+Conversation-creating ops gain an optional additive `workspaceId` (default `"default"`) so the backend
+stamps the conversation's workspace at creation:
+- `ChatRequest` (HTTP `POST /chat`) and `ChatSendMessage` (WS `chat.send`).
+- The queue ops that can start a turn: `POST /conversations/:id/queue` (`QueueRequest`) and `chat.queue` (WS).
+
+Note the FE mints `conversationId`s client-side and sends them on `chat.send`; the `workspaceId` travels
+alongside. **Existing invariant preserved:** `chat.send` still **omits `cwd`** (sends `undefined`) — the
+backend resolves the effective cwd from the workspace default (see 2.4).
+
+### 2.3 Workspace endpoints (your call on exact verbs/shapes)
+- **`GET /workspaces`** — list all (for the `/` home). Sorted by `lastActivityAt` desc. Enough for a picker
+ (id, title, timestamps; a conversation count would be nice but optional).
+- **`GET /workspaces/:id`** — **create-on-miss**: if absent, create it (`title = id`, `defaultCwd = null`)
+ and return it; if present, return it. Idempotent. This is the route-enter action when the user visits
+ `/<id>`. (We note GET-with-side-effects; you already lazy-spawn LSP on `GET /conversations/:id/lsp`, so
+ there's precedent — but feel free to propose `PUT`/upsert instead; see open Q4.)
+- **`PUT /workspaces/:id/title`** (body `{ title }`) — rename (display only; id/URL unchanged).
+- **`PUT /workspaces/:id/default-cwd`** (body `{ defaultCwd }` or `{ cwd }`) — set the workspace default cwd.
+ `null`/empty = "no default; fall through to the server default."
+- **`DELETE /workspaces/:id`** — delete a workspace. **Open question (Q1): what happens to its conversations.**
+
+### 2.4 cwd resolution (backend-owned — the FE does NOT re-implement)
+At turn time, resolve cwd as: **explicit conversation cwd (`GET /conversations/:id/cwd`)
+> `workspace.defaultCwd` > server default.**
+- `GET /conversations/:id/cwd` should keep returning the **explicit** conversation cwd (`null` =
+ "inheriting the workspace default"), so the FE can render "inherited from workspace X" vs "explicit."
+ The FE reads `workspace.defaultCwd` separately to show the inherited value.
+- `GET /conversations/:id/lsp` must root at the **effective** cwd (conversation cwd ?? `workspace.defaultCwd`),
+ so LSP spawns against the workspace default when the conversation hasn't set its own.
+
+### 2.5 The `default` workspace
+- Always present, **non-deletable**, id `"default"`. It is the fallback for unassigned conversations. The FE
+ navigates to `/default` for it. **Please confirm** the backend guarantees its existence on boot and rejects
+ `DELETE /workspaces/default`.
+
+### 2.6 Conversation list filtered by workspace
+- `GET /conversations` gains an additive **`?workspaceId=<id>`** filter (composable with the existing
+ `?status=` and `?q=`). Used by the FE to restore a workspace's active/idle conversations as tabs on a new
+ device (the existing cross-device tab-restore path, now workspace-scoped). The FE is **not** building a
+ full conversation-browser sidebar in this iteration — just the restore query.
+
+---
+
+## 3. Open questions for the backend to analyze + decide (FE will adapt)
+
+These are implementation decisions the FE defers to the backend. Please analyze each, pick an approach,
+and ask back wherever the FE's requirement is unclear or conflicts with your design:
+
+1. **Delete a workspace → fate of its conversations.** Reassign them to `"default"`? Block deletion while
+ non-empty? Delete them? The FE's only hard requirement: deleting a workspace must **not orphan/hide**
+ conversations (they must remain reachable). You decide semantics; the FE renders whatever you return.
+2. **"Clear to inherit" for conversation cwd.** With inheritance, a user may want to unset an explicit
+ conversation cwd to fall back to the workspace default. Today `PUT /conversations/:id/cwd` with `""` ⇒
+ `400 { error }`. Do you want to (a) treat `null`/empty as "inherit" (relax the 400), (b) add
+ `DELETE /conversations/:id/cwd`, or (c) defer the affordance (v1: a conversation cwd, once set, can be
+ changed but not unset)? The FE is fine with any.
+3. **Workspace lifecycle over WS.** For live cross-device refresh of the home list (a workspace
+ created/renamed/deleted on device A appears on device B's `/`), should you broadcast
+ `workspace.created` / `workspace.updated` / `workspace.deleted` (mirroring `conversation.statusChanged`),
+ or is the FE's fetch-on-mount + manual refresh sufficient for v1? The FE can ship either way; WS push is
+ a nice-to-have, not a blocker.
+4. **create-on-miss vs. explicit create.** Is `GET /workspaces/:id` create-on-miss acceptable, or would you
+ prefer a distinct `POST /workspaces` / `PUT /workspaces/:id` (upsert) the FE calls on route-enter? The FE
+ just needs "visit URL ⇒ workspace exists" in one round-trip.
+5. **Slug validation.** The id is a URL path segment. FE proposes URL-safe `[a-z0-9-]`, lowercase,
+ length-bounded. Please define the canonical validation + whether you normalize or reject. Also: should
+ creation reject slugs colliding with reserved names (`default`)?
+6. **Where do the types live?** `Workspace` in `@dispatch/wire` (like `ConversationMeta`) or
+ `@dispatch/transport-contract` (with the endpoints)? And the request/response shapes for the workspace
+ endpoints — your call; mirror the existing `TitleResponse`/`SetTitleRequest` style if it fits.
+7. **Cross-device tab set (confirm the FE's model).** The *open-tab set* stays **per-device** (FE
+ localStorage); cross-device sync covers workspaces + conversations + assignments, and a fresh device
+ restores a workspace's active/idle conversations as tabs via
+ `GET /conversations?workspaceId=<id>&status=active,idle`. Does the backend need to do anything beyond
+ exposing `workspaceId` on `ConversationMeta` + the `?workspaceId=` filter? (FE assumes **no**.)
+8. **Compaction interaction.** `conversation.compacted` yields a `newConversationId` (see
+ `ConversationCompactedMessage`/`CompactionResult`). Should the new (post-compaction) conversation inherit
+ the original's `workspaceId`? The FE assumes **yes**.
+
+---
+
+## 4. How the FE will consume it (so you can shape the contract)
+
+- Route enter `/<id>` → `GET /workspaces/:id` (create-on-miss) → render the workspace view + its tabs.
+- `/` home → `GET /workspaces` → list; delete via `DELETE /workspaces/:id`; (maybe) a "new workspace" input
+ that navigates to `/<slug>` (creation via the visit).
+- New conversation in workspace W → `chat.send` / `POST /chat` with `workspaceId: W.id` (cwd still omitted).
+- The CwdField (the renamed `cwd-lsp` module) shows explicit cwd (`GET /conversations/:id/cwd`) vs inherited
+ (`workspace.defaultCwd`).
+- On workspace entry, restore active/idle conversations as tabs:
+ `GET /conversations?workspaceId=<id>&status=active,idle`.
+
+---
+
+## 5. Priority / sequencing
+
+Not a hard blocker — the FE can begin FE-only scaffolding (the routing adapter + a workspace-view shell
+with provisional local types) in parallel, but the **cross-repo types must land before the FE wires real
+data.** Please bump `wire` / `transport-contract` / `ui-contract` as needed and note the changes in the
+reply so the FE re-pins the `file:` deps + re-mirrors the relevant `.dispatch/*.reference.md`.
+
+**Asks back to you, the backend:** (a) a review of the above for feasibility/fit; (b) a concrete
+implementation plan (final types, endpoints, resolution mechanics); (c) answers/decisions on Q1–Q8; and
+(d) any gaps or questions YOU have about the FE's requirements. The FE will then build against the landed
+contract.
diff --git a/backend-handoff.md b/backend-handoff.md
index 2768493..c82d9cb 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -2,42 +2,52 @@
> **Purpose:** the single rolling document the FE orchestrator keeps current so the user can hand off
> the whole FE↔backend seam at any time — on completion OR at a roadblock. Updated continuously.
-> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
+> **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-22 (context window + percentage-based compact consumed).
-**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** 686 tests green.
-**Open asks: NONE.** All CRs resolved (CR-1 through CR-6) + context-window + compact-percent
-handoff consumed._
+_Last updated: 2026-06-24 (`transport-contract` re-pinned 0.20.0 → 0.22.0; `file:` dep paths
+fixed `../arch-rewrite` → `../dispatch-backend`; MCP server-status endpoint/types consumed + backend shipped
+`GET /conversations/:id/mcp` (CR-12 resolved); transient `provider-retry` AgentEvent consumed (additive to
+`[email protected]`, no version bump))._
+**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
+(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump).
+Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11
+(per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three.
+The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to
+`[email protected]` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c.
+FE re-pinned + re-mirrored `transport-contract`; `selectModel` persists to
+`PUT /conversations/:id/model` and conversation focus recalls the persisted model via `GET /conversations/:id/model`.
+FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`) — see §2b.
---
## 1. Pinned backend contracts (consumed by the FE)
-Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
+Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
| Package | Used for |
|---|---|
| `@dispatch/ui-contract` | surfaces + surface WS protocol |
-| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` |
-| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` |
+| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` |
+| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` |
Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`):
`POST /chat` (NDJSON) · `GET /models` ·
`GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` (CR-5 windowing) ·
`GET /conversations/:id/metrics` · `GET`/`PUT /conversations/:id/cwd` ·
`GET`/`PUT /conversations/:id/reasoning-effort` (sticky thinking-depth; `null` ⇒ default `high`) ·
-`GET /conversations/:id/lsp` · `POST /chat/warm` · `POST /conversations/:id/close` (explicit
+`GET`/`PUT /conversations/:id/model` (sticky per-conversation model persistence) ·
+`GET /conversations/:id/lsp` · `GET /conversations/:id/mcp` (MCP server status; mirrors `/lsp`) · `POST /chat/warm` · `POST /conversations/:id/close` (explicit
tab-close: abort turn + stop/disable warming) · `POST /conversations/:id/queue` (enqueue
steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` ·
WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) ·
WS `chat.queue` (enqueue steering; fire-and-forget — surface updates on success) ·
-WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab) ·
-WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`).
+WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab; carries `workspaceId`) ·
+WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`).
Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md`
(regenerate on any contract bump; all current as of `[email protected]` /
### FE invariants to keep (don't regress)
@@ -48,11 +58,133 @@ Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-con
`StoredChunk`). The FE derives `hasOlder = oldestLoaded.seq > 1`.
- **Warming opt-in is NOT re-hydrated across a backend restart** — a conversation reads disabled
until toggled again (fail-safe). Backend offered boot hydration if it becomes a product need.
+- **`PUT /conversations/:id/cwd` sends the active `workspaceId`** (CR-8) so a relative cwd set on a
+ new/draft tab is assigned to the workspace BEFORE persisting — the subsequent `GET /lsp` then
+ resolves it against `workspace.defaultCwd`. The store reads `activeWorkspaceId` with `untrack` at
+ call time (never reactive inside the async). `chat.send` still omits `cwd` (the persisted cwd wins).
+- **`conversation.open` and `conversation.statusChanged` WS broadcasts carry `workspaceId`** so tabs opened
+ by the CLI `--open` flag (or by a cross-device `active` status change) are stamped with the conversation's
+ actual workspace instead of the viewer's current workspace. The FE ignores any broadcast missing `workspaceId`
+ (parser returns null) — acceptable because the backend contract is updated in lockstep.
---
## 2. Open asks FOR THE BACKEND
+### CR-9 — `system:os` variable: include WSL detection + Linux distro → **OPEN**
+
+The `system:os` system-prompt variable (resolved by the backend at construction time) should
+return a richer OS string:
+
+1. **WSL detection** — when running under Windows Subsystem for Linux, the resolved `system:os`
+ value should indicate WSL (e.g. `"Linux (WSL)"` or `"WSL2"` rather than just `"Linux"`).
+ Detection: check for the presence of `/proc/sys/fs/binfmt_misc/WSLInterop` or
+ `Microsoft` in `/proc/version`, or the `WSL_DISTRO_NAME` environment variable.
+2. **Linux distro** — on Linux, include the distribution name (e.g. `"Ubuntu 22.04"` or
+ `"Ubuntu"` rather than just `"Linux"`). Source: `/etc/os-release` (`PRETTY_NAME` or
+ `NAME`/`VERSION_ID`).
+
+No wire/transport-contract/ui-contract change needed — this is a backend behavior change in
+how the `system:os` variable is resolved (the type shape is unchanged: it's still a `string`).
+The FE is unaffected (it only inserts `[system:os]` into the template; the backend resolves it).
+
+### CR-7 — Workspace cwd fallthrough bug + relative-path resolution → **RESOLVED ✅ (backend shipped; FE code unchanged)**
+
+Fixed backend-side (reply from arch-rewrite agent ab13). **No wire/transport-contract/ui-contract
+bumps needed; FE does NOT need a re-pin or re-mirror.**
+
+**What was fixed:**
+1. Workspace `defaultCwd` now applies when the conversation has no explicit per-conversation cwd.
+2. A per-turn `cwd` (or persisted `cwd`) that is **relative** is now resolved against the workspace
+ `defaultCwd`, not raw → falls through to `process.cwd()`.
+3. `DELETE /conversations/:id/cwd` now actually clears the persisted cwd (was a no-op stub).
+4. New-conversation timing: first turn assigns the workspace before resolving cwd, so a relative
+ per-turn cwd on a brand-new conversation resolves against the correct workspace.
+
+**Resolution algorithm (backend-owned):**
+```
+workspaceCwd = workspace?.defaultCwd ?? null
+conversationCwd = persisted per-conversation cwd OR per-turn cwd from chat.send (null if omitted)
+if (conversationCwd == null) effectiveCwd = workspaceCwd ?? serverDefaultCwd // process.cwd()
+else if (conversationCwd[0] === "/") effectiveCwd = conversationCwd
+else effectiveCwd = path.resolve(workspaceCwd ?? serverDefaultCwd, conversationCwd)
+```
+
+**FE impact:** none — the FE already sends `workspaceId` and **omits `cwd`** on `chat.send`
+(`src/features/chat/store.svelte.ts`). The persisted cwd is set separately via `PUT /conversations/:id/cwd`,
+and the backend resolves it at turn start. `GET /conversations/:id/cwd` still returns the raw explicit
+value (e.g., `"gameplay"`) for the CwdField; `GET /conversations/:id/lsp` returns the resolved effective
+cwd.
+
+**Optional future FE enhancement:** Add a "Clear" button to CwdField that calls
+`DELETE /conversations/:id/cwd`, letting the user reset a conversation to inherit the workspace
+`defaultCwd`. Not required for the fix.
+
+---
+
+### CR-10 — `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts → **RESOLVED ✅ (`[email protected]`; FE consumed)**
+
+**Bug:** When summoning an agent via the CLI `--open --workspace <id>` flag, the tab opened across
+ALL workspaces instead of just the one it was assigned to. The backend knew the conversation's
+workspace but dropped it from the broadcast — the `ConversationOpenMessage` and
+`ConversationStatusChangedMessage` WS messages carried only `conversationId`. The FE then fell back
+to stamping the tab with `activeWorkspaceId` (the viewer's current workspace), so the tab appeared in
+every open browser tab's workspace view.
+
+**Backend fix (shipped):** Additive `workspaceId: string` on both broadcast messages
+(`[email protected]`). The backend resolves the conversation's persisted workspace
+(`"default"` fallback) at broadcast time — not the per-turn start option — and includes it in the
+`conversation.open` and `conversation.statusChanged` fan-out.
+
+**FE fix (consumed):**
+- WS parser (`src/adapters/ws/logic.ts`): parse + require `workspaceId` on both message types.
+- `openConversation()` (`src/app/store.svelte.ts`): signature changed to
+ `(conversationId, workspaceId)`; the tab is stamped with the message's `workspaceId`, not
+ `activeWorkspaceId`. The `onConversationOpen` and `onConversationStatusChanged` handlers pass
+ `msg.workspaceId` through.
+- Re-mirrored `.dispatch/transport-contract.reference.md`.
+- Tests updated: `logic.test.ts`, `index.test.ts`, `conformance.test.ts`.
+
+**Note:** The FE parser now rejects `conversation.open` / `conversation.statusChanged` messages
+missing `workspaceId` (returns null). This is acceptable because the backend contract is updated in
+lockstep; a mixed-version setup (old backend + new FE) would silently drop those broadcasts.
+
+---
+
+### CR-11 — Per-conversation model persistence → **RESOLVED ✅ (`[email protected]`; FE consumed)**
+
+**Backend (shipped):**
+- `[email protected]` adds `ModelResponse` and `SetModelRequest`.
+- New endpoints:
+ - `GET /conversations/:id/model` returns `{ conversationId, model: string | null }`.
+ - `PUT /conversations/:id/model` with body `{ model: string | null }` persists or clears the
+ per-conversation sticky model selection.
+- The backend resolves the model per turn: explicit `ChatRequest.model` override wins, else persisted
+ model for the conversation, else the server default.
+
+**FE (consumed):**
+- Imported `ModelResponse` + `SetModelRequest`; re-mirrored `.dispatch/transport-contract.reference.md`.
+- Added `refreshModel()` (`src/app/store.svelte.ts`) — fetches via `GET /conversations/:id/model` on every
+ focus change (tab switch, workspace switch, boot, reconnect) and updates `activeModel`, the active tab's
+ stored model, and the active chat store's model when a non-null model is returned.
+- Updated `selectModel(model)` to persist the choice via `PUT /conversations/:id/model` when a real
+ conversation tab is active; drafts still only update session-local state.
+- Tests added (`src/app/store.test.ts`): model selection triggers a `PUT /model` with the right body,
+ and a persisted model is recalled when focusing a new conversation.
+
+---
+
+### Workspaces — backend-owned conversation grouping with a default cwd → **RESOLVED ✅ (backend shipped; FE build in progress)**
+
+A **workspace** is a URL-driven (`/<id>`) grouping of conversations that owns a default cwd (used by its
+conversations that haven't set their own). Backend-owned so cross-device just works. The backend shipped
+the finalized contract (`[email protected]`/`[email protected]`): `Workspace`/`WorkspaceEntry` types,
+`workspaceId` on `ConversationMeta` + `ChatRequest`/`QueueRequest`/`ChatQueueMessage`, workspace endpoints
+(`GET /workspaces`, `PUT`/`GET /workspaces/:id`, `PUT .../title`, `PUT .../default-cwd`,
+`DELETE /workspaces/:id`), `?workspaceId=` on `GET /conversations`, and `DELETE /conversations/:id/cwd`
+(clear-to-inherit). Q1–Q8 decisions + full shapes in `backend-handoff-workspaces-reply.md`. FE re-pinned +
+re-mirrored; FE feature build in progress.
+
### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending)
The backend now persists chunks **incrementally at step boundaries** during generation:
@@ -82,6 +214,9 @@ the turn seals and `syncTail` fetches everything.
| CR | Summary | Status |
|---|---|---|
+| CR-12 | `GET /conversations/:id/mcp` MCP server-status endpoint (mirrors `/lsp`) | ✅ `[email protected]`; backend shipped; FE consumed + verified (3 LSP-shape diffs handled) |
+| CR-10 | `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts | ✅ `[email protected]`; FE consumed |
+| CR-7 | Workspace cwd fallthrough bug + relative-path resolution | ✅ resolved (backend-only) |
| CR-1 | Loaded Extensions as a true table (`rendererId: "table"`) | ✅ shipped + consumed |
| CR-2 | catalog `scope` flag (`"global"` / `"conversation"`) | ✅ `[email protected]` |
| CR-3 | `user-message` event (watcher sees user prompt mid-turn) | ✅ `[email protected]` |
@@ -91,6 +226,126 @@ the turn seals and `syncTail` fetches everything.
---
+## 2b. MCP server status slice → **RESOLVED ✅ (backend shipped CR-12; FE verified)**
+
+Consumes the backend's `GET /conversations/:id/mcp` endpoint, mirroring `GET /conversations/:id/lsp`
+exactly. Contract types in `[email protected]`: `McpServerState`
+(`"connecting" | "connected" | "error" | "disconnected"`), `McpServerInfo`
+(`{ id, state, error?, toolCount, configSource? }`), `McpStatusResponse`
+(`{ conversationId, cwd: string|null, servers: McpServerInfo[] }`). Full backend handoff:
+`../dispatch-backend/frontend-mcp-status-handoff.md`; shape/behavior details:
+`../dispatch-backend/reports/transport-http-mcp.md`.
+
+**Backend (shipped CR-12):** endpoint behaves identically to `/lsp` — no persisted cwd →
+`{ cwd: null, servers: [] }` (HTTP 200, empty); MCP extension not loaded →
+`503 { error: "MCP service not available" }`. The FE handles both gracefully (empty/no-cwd/503 paths
+never crash the renderer; the 503 `error` string surfaces in red).
+
+**FE (DONE + verified against the 3 LSP-shape differences the backend flagged):**
+- Re-pinned `transport-contract` 0.20.0 → 0.22.0; re-mirrored `.dispatch/transport-contract.reference.md`
+ (added the MCP section + the previously-missing `configSource` on `LspServerInfo`).
+- New feature library `src/features/mcp/`: pure `logic/view-model.ts` (`viewMcpServer`/`viewMcpServers`/
+ `summarizeMcpServers`, state→badge/label/busy mapping), `ui/McpStatusView.svelte` (mirrors
+ `LspStatusView`'s structure — refresh button, loading, summary, server list), `index.ts`
+ (`McpStatusView`/`manifest`/types). 9 view-model tests green.
+- `AppStore.mcpStatus()` (`src/app/store.svelte.ts`) — mirrors `lspStatus()`: normalizes the untyped
+ body at the network seam (`servers` guaranteed an array), returns `McpResult | null`.
+- Wired into `src/app/App.svelte`: `"mcp"` view kind, `loadMcpStatus` adapter, `McpStatusView` in the
+ `viewContent` snippet (re-mounts per conversation via `{#key store.currentConversationId}`).
+
+**Three shape differences from LSP — verified handled, do NOT regress:**
+1. **Fields differ.** `McpServerInfo` has only `{ id, state, error?, toolCount, configSource? }` — NO
+ `name`/`root`/`extensions`. The MCP row markup (`McpStatusView.svelte`) renders `id`/state badge/
+ `toolCount`/`error`/`configSource` only; it does NOT reference any LSP-only field. `McpServerView`
+ is a distinct type from `LspServerView` (the latter carries `name`/`root`/`extensionsLabel`). Do not
+ reuse the LSP row component verbatim for MCP.
+2. **Enum differs.** `McpServerState = "connecting" | "connected" | "error" | "disconnected"` — a
+ DIFFERENT enum from LSP's `"connected" | "starting" | "error" | "not-started"`. `viewMcpServer`'s
+ switch handles all four MCP cases (exhaustive vs the contract): `connected`→success, `connecting`→
+ warning+busy(spinner), `disconnected`→neutral (NOT busy — a stable idle state), `error`→error. Do
+ not share the LSP badge mapping.
+3. **`configSource?` is currently always absent** on this path (the `McpServerStatus` source doesn't
+ carry it) but the wire type leaves it optional. The FE renders it defensively: view-model coerces
+ `server.configSource ?? null`; the template guards `{#if server.configSource}` with an empty-`<span>`
+ fallback, so absence renders nothing. Verified correct for the current always-absent reality.
+
+**Live probe:** NOT run — the backend was not reachable on `:24203` at verify time (the backend is the
+user's process; never booted headless). The unit suite (775 tests, incl. 9 mcp) is green; a live probe
+against a running backend remains a nice-to-have for the actual network seam (it's a plain HTTP GET,
+no WS). Start the backend and run `bun scripts/live-probe.ts` (or just open the MCP Servers sidebar view)
+to confirm end-to-end.
+
+---
+
+## 2c. Transient `provider-retry` AgentEvent → **CONSUMED ✅ (backend shipped; FE wired + tested)**
+
+The backend now retries retryable provider errors (e.g. "server overloaded" HTTP 429/5xx) with a stepped
+backoff (5s→10s→30s→60s→5m→10m→15m→30m→repeat, up to an 8h budget). Each scheduled retry emits a NEW
+**transient** `AgentEvent`: `provider-retry`. The FE renders it as a **yellow warning system-message
+bubble**; the actual model reply still streams normally after a retry succeeds. Contract types are in
+`[email protected]` (additive — the type was added to the existing version, no bump; the FE's `file:` dep picks
+it up automatically, no re-pin needed). `TurnProviderRetryEvent`:
+`{ type: "provider-retry", conversationId, turnId, attempt (0-based), delayMs, message, code? }`.
+
+**FE (DONE + verified):**
+- Re-mirrored `.dispatch/wire.reference.md` (added `TurnProviderRetryEvent` to the `AgentEvent` union +
+ its interface). The FE already resolved the new type via the `file:` symlink (no re-pin).
+- `src/core/chunks/types.ts`: added `providerRetry: TurnProviderRetryEvent | null` to `TranscriptState`
+ (mirrors the `generating` UI-indicator pattern — event-stream-derived state that is NOT a chunk).
+- `src/core/chunks/reducer.ts`: `foldEvent` SETS the banner on a `provider-retry` (coalescing — latest
+ attempt+delay replaces previous → a single updating banner) and CLEARS it when content resumes
+ (`text-delta`/`reasoning-delta`/`tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`),
+ or a new turn starts (`turn-start`); metadata events (`status`/`usage`/`step-complete`/`tool-output`)
+ leave it untouched. The clearing is centralized via a `RETRY_CLEARING_EVENTS` Set in a thin `foldEvent`
+ wrapper over the renamed inner `reduceEvent`. `clearGenerating` (WS reconnect) also drops a stale banner.
+- `src/core/wire/conformance.ts` + `.test.ts`: added the `provider-retry` case to the exhaustiveness guard
+ and bumped the variant count 14 → 15 (the `satisfies never` guard is what flagged the new variant).
+- `src/core/chunks/selectors.ts` (`selectProviderRetry`) + new `retry-banner.ts` view-model
+ (`viewProviderRetry` → `{ attemptLabel: "Retry #N", delayLabel: "5s"/"30m", message, code }`,
+ `formatRetryDelay`), exported from `chunks/index.ts` + re-exported from `features/chat/index.ts`.
+- `src/features/chat/store.svelte.ts`: `providerRetry` getter + `ChatStore` interface field (mirrors
+ `generating`).
+- `src/features/chat/ui/ChatView.svelte`: new `providerRetry` prop → renders a DaisyUI `alert alert-warning`
+ yellow bubble at the end of the transcript (where the reply would appear), with `⚠ Retry #N — retrying in
+ {delay}…`, a `code` badge, and the endpoint error verbatim. Re-mounted per conversation.
+- `src/app/App.svelte`: passes `providerRetry={store.activeChat.providerRetry}`.
+- 19 new tests (reducer: set/coalesce/clear-on-resume/clear-on-turn-end/clear-on-new-turn/clear-on-reconnect/
+ not-a-chunk/metadata-preserves; retry-banner: delay formatting + view-model). 775 tests green.
+
+**Transient / never-persisted guarantee (the critical invariant):** `provider-retry` is NEVER a `Chunk`
+(it's not in the `Chunk.type` union — `assertChunkExhaustive` is unchanged). It lives only in
+`TranscriptState.providerRetry`, set/cleared by `foldEvent`. It never enters `committed` (seq'd history) or
+`provisional`, so it can NEVER pollute the model's prompt or be sent back as a message. On a reload/replay of
+past turns it is NOT replayed (only committed seq'd chunks are history) — `providerRetry` starts null and is
+set only when a NEW `provider-retry` arrives. On a WS reconnect mid-turn, `clearGenerating` drops any stale
+banner (past retries aren't replayed). If the 8h budget exhausts, the existing terminal `error` `AgentEvent`
+fires and seals the turn — rendered as the existing error state (the banner is cleared by the `error` case).
+
+**Note on `step-complete` timing:** when retries occur, the `step-complete` event's `genTotalMs` includes
+the retry-sleep time (backend-side, cosmetic). The FE surfaces per-step timing unchanged — no action needed.
+
+**Open follow-up (optional, not blocking):** the "countdown" is a STATIC label derived from `delayMs`
+("retrying in 5s…"), matching the backend's examples — NOT a live ticking timer (which would be a component
+effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be added later as a Svelte
+`$effect`/`setInterval` in ChatView if desired, but the static label is accurate and keeps the component thin.
+
+**Live probe (run, backend up):** `scripts/live-probe-provider-retry.ts` — 8/8 checks passed:
+- REGRESSION: a real text turn through the REAL WS socket + the updated `foldEvent` sealed cleanly and
+ `providerRetry` stayed NULL throughout (no spurious banner; the `reduceEvent` wrapper didn't break streaming).
+ (The repo-wide `scripts/live-probe.ts` also still passes 23/23 — text/tool/metrics/CR-5 all green with the
+ updated reducer.)
+- PARSER+REDUCER SEAM: a synthetic `provider-retry` `chat.delta` JSON frame through the REAL
+ `parseServerMessage` is ACCEPTED (not rejected as unknown) → `foldEvent` SETS `providerRetry` (attempt/
+ delayMs/code correct) and adds NO chunk → a 2nd coalesces → a subsequent `text-delta` CLEARS it and the
+ reply lands as a chunk. This is the JSON-parse boundary the unit tests skip (they pass constructed events).
+- NOT exercised live: the actual banner rendering in the browser (a real `provider-retry` from an overloaded
+ provider can't be forced from a probe — it needs a human at the page with a 429'ing provider). The full
+ data path (wire parse → reducer → state) IS verified live; only the Svelte render of the yellow bubble
+ remains a human-confirm (open the chat, trigger an overloaded provider, confirm the yellow "⚠ Retry #N —
+ retrying in 5s…" banner appears, updates per attempt, and clears when the reply streams).
+
+---
+
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
diff --git a/bun.lock b/bun.lock
index 8983dd1..a13b57a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,9 +5,9 @@
"": {
"name": "dispatch-web",
"dependencies": {
- "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract",
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire",
+ "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract",
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire",
"dompurify": "^3.4.5",
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
@@ -34,8 +34,8 @@
},
},
"overrides": {
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire",
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire",
},
"packages": {
"@adobe/css-tools": ["@adobe/[email protected]", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -76,11 +76,11 @@
"@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
- "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../arch-rewrite/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }],
+ "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../dispatch-backend/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }],
- "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}],
+ "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}],
- "@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}],
+ "@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -558,9 +558,9 @@
"zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
- "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}],
+ "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}],
- "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}],
+ "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md
new file mode 100644
index 0000000..ebf2ff8
--- /dev/null
+++ b/notes/assumptions-log.md
@@ -0,0 +1,58 @@
+# Assumptions log (dispatch-web)
+
+> Recorded while working autonomously (user away). Raise these when the user returns.
+
+## 2026-06-24 — CR-10: workspaceId on conversation.open / statusChanged
+
+1. **Contract version already at 0.19.0 in the backend repo.** The `file:` dep in `package.json`
+ points to `../arch-rewrite/packages/transport-contract`, which was already at `0.19.0` when the
+ handoff arrived. No `package.json` version string needed changing — `bun install` re-synced the
+ symlink. If the backend repo is later rolled back, the FE will fail to compile (the new required
+ `workspaceId` field won't exist on the type).
+
+2. **The `dist/` directory is owned by root** (likely from a previous root-run build). `bun run build`
+ fails with `EACCES` when Vite tries to empty `dist/assets`. This is an environment issue, not a
+ code issue — I verified the build succeeds with `--outDir dist-tmp` (a fresh directory I own, then
+ removed). The user may want to `sudo chown -R tradam:tradam dist/` or remove it.
+
+3. **Parser now rejects broadcasts missing `workspaceId`.** The WS parser (`logic.ts`) returns `null`
+ for `conversation.open` / `conversation.statusChanged` messages that lack a string `workspaceId`.
+ This is a breaking change for a mixed-version setup (old backend + new FE) — those broadcasts
+ would be silently dropped. Assumed acceptable because the backend contract is updated in lockstep.
+
+4. **Tab is opened but not focused when the conversation's workspace differs from the active one.**
+ The FE creates the tab (stamped with the correct `workspaceId`) but does NOT navigate to that
+ workspace or switch the active tab. The tab is hidden by the `tabs` getter filter
+ (`t.workspaceId === activeWorkspaceId`) until the user navigates to the correct workspace. This
+ matches the original "open without switching" behavior of the `conversation.open` handler. If the
+ product wants auto-navigation to the conversation's workspace on `--open`, that's a separate FE
+ product decision.
+
+5. **CR-10 numbering.** The prior open ask was CR-9 (still open). I assigned this fix CR-10 in the
+ handoff doc for tracking. If the backend used a different CR number, adjust.
+
+## 2026-06-24 — CR-11: Per-conversation model persistence
+
+1. **`refreshModel()` is called on every focus change** (boot, tab switch, workspace switch,
+ reconnect) mirroring `refreshCwd` / `refreshReasoningEffort` / `refreshCompactPercent`. For a
+ draft conversation the `GET /conversations/:id/model` request will 404 (the draft id isn't
+ persisted yet); `res.ok` is false so it's a silent no-op. This matches how the other refresh
+ functions behave for drafts.
+
+2. **`refreshModel` only applies a non-empty string model.** The backend returns `model: null` when
+ never set; the FE leaves `activeModel` unchanged in that case (falls back to the boot default or
+ the last-known tab model). This avoids resetting the selector to `null`/`undefined` and crashing
+ `ModelSelector`'s `splitModelName`. An empty string is also ignored defensively.
+
+3. **`selectModel` persists only for real conversation tabs, not drafts.** A draft has no persisted
+ conversation id yet; the first `chat.send` will carry `model` (the chat store still sends it),
+ and the backend persists it on turn start. Drafts update session-local state only — same as the
+ pre-change behavior.
+
+4. **No "clear model" UI affordance added.** The backend supports `PUT /model` with `{ model: null }`,
+ but the FE model selector has no "reset to default" button. This is a future product decision; the
+ persistence path is ready if/when the UI adds it.
+
+5. **No `model` field on `ConversationMeta`.** Following the precedent of `cwd` and `reasoningEffort`
+ (fetched via dedicated endpoints, not on the list response). The FE fetches model on focus, not
+ from `fetchOpenConversations`.
diff --git a/package.json b/package.json
index acef44b..dbd57c8 100644
--- a/package.json
+++ b/package.json
@@ -14,17 +14,17 @@
"check:fix": "biome check --write ."
},
"dependencies": {
- "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract",
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire",
+ "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract",
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire",
"dompurify": "^3.4.5",
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4"
},
"overrides": {
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire"
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire"
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
diff --git a/scripts/fix-dist-perms.sh b/scripts/fix-dist-perms.sh
new file mode 100755
index 0000000..471cbdf
--- /dev/null
+++ b/scripts/fix-dist-perms.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Fix ownership of dist/ so Vite can clean + rebuild it.
+# The dist/assets/ dir was created as root (likely a Docker build) and Vite
+# can't rmSync it as a non-root user → EACCES on `bun run build`.
+#
+# Usage: sudo ./scripts/fix-dist-perms.sh
+set -euo pipefail
+
+DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
+
+if [ ! -d "$DIST_DIR" ]; then
+ echo "No dist/ directory found — nothing to fix."
+ exit 0
+fi
+
+OWNER="$(stat -c '%U:%G' "$DIST_DIR")"
+echo "dist/ is currently owned by: $OWNER"
+
+if [ "$OWNER" = "root:root" ]; then
+ echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..."
+fi
+
+# chown the whole dist/ tree to the same owner as the repo root
+chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR"
+echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")"
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
new file mode 100644
index 0000000..22f1794
--- /dev/null
+++ b/scripts/live-probe-provider-retry.ts
@@ -0,0 +1,188 @@
+/**
+ * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient
+ * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up).
+ * NOT part of `bun run test`.
+ *
+ * A real `provider-retry` only fires on an upstream 429/5xx, which we can't
+ * force from here. So this probe verifies the two things unit tests CAN'T:
+ *
+ * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket +
+ * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper)
+ * seals cleanly and `providerRetry` stays NULL throughout — no spurious
+ * banner, and the wrapper's re-spread didn't break streaming.
+ * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a
+ * synthetic `provider-retry` `chat.delta` JSON string through the REAL
+ * `parseServerMessage` wire parser (the function that runs on every inbound
+ * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) →
+ * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk →
+ * a subsequent `text-delta` CLEARS it. This proves the new variant survives
+ * the JSON-parse boundary the unit tests skip (they pass constructed events).
+ *
+ * bun scripts/live-probe-provider-retry.ts
+ * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts
+ */
+import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract";
+import type { SurfaceServerMessage } from "@dispatch/ui-contract";
+import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
+import { parseServerMessage } from "../src/adapters/ws/logic.ts";
+import {
+ foldEvent,
+ initialState,
+ selectChunks,
+ selectProviderRetry,
+} from "../src/core/chunks/index.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok";
+
+type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
+
+const checks: { name: string; ok: boolean; detail?: string }[] = [];
+const record = (name: string, ok: boolean, detail?: string) => {
+ checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+};
+const fail = (msg: string): never => {
+ console.error(`\n[probe] FATAL: ${msg}`);
+ process.exit(1);
+};
+
+/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */
+function deltaFrame(event: ChatDeltaMessage["event"]): string {
+ return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage);
+}
+
+async function main() {
+ console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`);
+
+ // ─── 1. REGRESSION: a real text turn through the updated foldEvent ──────────
+ // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts).
+ const handlers = new Map<string, (msg: ChatMsg) => void>();
+ const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (_m: SurfaceServerMessage) => {},
+ onChat: (msg: ChatMsg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ h?.(msg);
+ },
+ });
+ await new Promise((r) => setTimeout(r, 500));
+
+ const conversationId = crypto.randomUUID();
+ let state = initialState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
+
+ socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+
+ record(
+ "regression: a real text turn sealed cleanly",
+ sealed && error === null,
+ `${deltas} deltas${error ? ` err=${error}` : ""}`,
+ );
+ record(
+ "regression: providerRetry stayed NULL through a normal turn (no spurious banner)",
+ selectProviderRetry(state) === null,
+ );
+
+ // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─
+ console.log("\n[probe] parser+reducer seam (synthetic provider-retry)");
+ let s = initialState();
+ s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" });
+
+ const retryJson = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 0,
+ delayMs: 5000,
+ message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}',
+ code: "429",
+ });
+ const parsed1 = parseServerMessage(retryJson);
+ record(
+ "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)",
+ parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry",
+ parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null",
+ );
+
+ if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event);
+ const retry1 = selectProviderRetry(s);
+ record(
+ "foldEvent SETS providerRetry from the PARSED event",
+ retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429",
+ retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null",
+ );
+ record(
+ "provider-retry adds NO chunk (never persisted — never pollutes the prompt)",
+ selectChunks(s).length === 0,
+ `${selectChunks(s).length} chunk(s)`,
+ );
+
+ const retry2Json = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 1,
+ delayMs: 10000,
+ message: "HTTP 429: still overloaded",
+ code: "429",
+ });
+ const parsed2 = parseServerMessage(retry2Json);
+ if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event);
+ const retry2 = selectProviderRetry(s);
+ record(
+ "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)",
+ retry2 !== null &&
+ retry2.attempt === 1 &&
+ retry2.delayMs === 10000 &&
+ retry2.message === "HTTP 429: still overloaded",
+ retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null",
+ );
+
+ const textJson = deltaFrame({
+ type: "text-delta",
+ conversationId,
+ turnId: "t1",
+ delta: "here is the reply",
+ });
+ const parsedText = parseServerMessage(textJson);
+ if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event);
+ record(
+ "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)",
+ selectProviderRetry(s) === null,
+ );
+ record(
+ "…and the text-delta content DID land as a chunk (the reply streams normally after retries)",
+ selectChunks(s).some((c) => c.chunk.type === "text"),
+ );
+
+ socket.close();
+ const passed = checks.filter((c) => c.ok).length;
+ const total = checks.length;
+ console.log(`\n[probe] ${passed}/${total} checks passed`);
+ process.exit(passed === total ? 0 : 1);
+}
+
+main().catch((e) => fail(String(e)));