summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff-cache-warming.md102
-rw-r--r--backend-handoff-chat-limit.md66
-rw-r--r--backend-handoff-cwd-lsp.md61
-rw-r--r--backend-handoff-workspaces-reply.md216
-rw-r--r--backend-handoff-workspaces.md157
-rw-r--r--backend-handoff.md1209
6 files changed, 0 insertions, 1811 deletions
diff --git a/backend-handoff-cache-warming.md b/backend-handoff-cache-warming.md
deleted file mode 100644
index a0019f9..0000000
--- a/backend-handoff-cache-warming.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Cache-warming lifecycle handoff (FE → backend) — CR-4 — **RESOLVED ✅ 2026-06-12**
-
-> **Closed.** Backend reply: `../arch-rewrite/frontend-cache-warming-lifecycle-handoff.md`
-> (`[email protected]` + `[email protected]`). All asks shipped; FE consumed + live-probed
-> 17/17 (`scripts/probe-cache-warming.ts` against `bin/up`). CR-4d turned out to be an FE bug (our
-> WS parser dropped the `conversationId` echo on the initial `surface` message) — fixed FE-side.
-> Current status lives in `backend-handoff.md` §2. Original report kept below for history.
-
-> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user.
-> User-reported symptoms, investigated FE-side with a live probe against a running backend
-> (`bin/up2` stack, HTTP :25203 / surface WS :25205, 2026-06-12). Repro tool:
-> `dispatch-web/scripts/probe-cache-warming.ts` (drives the FE's real WS adapter + the
-> `cache-warming` surface; safe to re-run to verify fixes).
->
-> **Verdict up front:** the FE renders the surface data faithfully — symptoms 1 and 2 are
-> backend data/behavior; symptom 3 needs a new backend affordance (FE will wire it on arrival).
-
-## User-reported symptoms
-
-1. Warming is **ON by default** for a new conversation — the user has to manually turn it off.
- Wanted: default OFF, opt-in per conversation.
-2. With warming enabled, **no usable countdown** to the next refresh — the user can't tell
- whether refreshes are happening at all.
-3. Wanted lifecycle: refreshes **keep running when the browser window closes** (✅ already true,
- verified — see below), but **closing the conversation's tab in the app should stop the
- refreshes AND abort any in-flight generation** (closing the tab = "done with this chat for now").
-
-## Probe evidence (verbatim observations)
-
-Fresh conversation (first turn sealed), then `subscribe {surfaceId:"cache-warming", conversationId}`:
-
-- **Initial spec:** `toggle value: true`, `number value: 240` (s), timer payload
- `{ nextWarmAt: <now+240s>, lastWarmAt: null }` → **enabled by default, warm already scheduled**.
- Confirms symptom 1 is backend default state.
-- `invoke cache-warming/set-interval payload:20` → update with a FUTURE `nextWarmAt` (+20s). ✅
-- **Automatic warms DO repeat and DO push updates** — 3 warms observed at ~21s spacing
- (interval 20s), each pushing an `update` with fresh `Last Cache %` / `Cache retention` stats.
- So the engine itself works.
-- **BUG (symptom 2 root cause): every post-warm `update` carries a STALE `nextWarmAt` — the fire
- time of the warm that JUST completed (i.e. in the past), never the next scheduled one.**
- Observed sequence (epoch ms):
-
- | update after | nextWarmAt | lastWarmAt | note |
- |---|---|---|---|
- | warm #1 | 1781246273405 | 1781246274299 | nextWarmAt < lastWarmAt (past) |
- | warm #2 | 1781246294299 | 1781246295269 | = warm#1.lastWarmAt + 20 000 → still past |
- | warm #3 | 1781246315269 | 1781246315998 | = warm#2.lastWarmAt + 20 000 → still past |
-
- The pattern shows the reschedule math exists (`next = lastWarm + interval`) but the surface
- update is emitted with the PRE-warm snapshot; the post-reschedule (future) `nextWarmAt` is
- never pushed. The FE countdown is authoritative off `nextWarmAt` (per the cache-warming
- handoff design), so after the FIRST automatic warm the UI shows "Next warm in 0s" forever —
- exactly the user's "I can't tell if it's working".
-- Same staleness after a real chat turn while subscribed: last update after `turn-sealed` still
- carried a past `nextWarmAt` (−10s and counting), even though a warm was presumably scheduled.
-- **Browser-closed continuity ✅:** the schedule is fully server-side — warms fired with no
- browser attached (only the headless probe socket). Symptom 3's "keep running when the window
- closes" half already works; do not regress it.
-- **Contract deviation (minor):** the initial `surface` reply to a conversation-scoped subscribe
- does NOT echo `conversationId` (updates do). `ui-contract` says the echo should be present
- ("echoes the subscribe's conversation … so the client routes it"). The FE currently tolerates
- the missing echo (treats no-echo as current), but that weakens stale-scope filtering on fast
- conversation switches — please echo it.
-
-## Asks
-
-### CR-4a — default warming to OFF for a new conversation
-New conversations currently start `enabled: true`, interval 240s, first warm scheduled
-immediately. Make the default `enabled: false` (no warm scheduled until the user opts in).
-No contract change — it's the initial state of the existing surface.
-
-### CR-4b — push the refreshed (future) `nextWarmAt` after each automatic warm
-After a warm completes + the next one is scheduled, the emitted surface `update`'s
-`cache-warming-timer` payload must carry the NEW future `nextWarmAt` (and the new `lastWarmAt`).
-Either emit the update after rescheduling or emit a second update — FE is indifferent; it just
-renders the authoritative timestamp. (Same applies to the post-`turn-sealed` reschedule path.)
-No contract change — it's the payload of the existing custom field.
-
-### CR-4c — a "conversation closed" affordance (stop warming + abort generation)
-The FE needs to tell the backend "the user closed this conversation's tab": that should
-(1) disable/stop cache-warming for the conversation and (2) abort any in-flight turn.
-Today there is no path:
-- `chat.unsubscribe` / socket close explicitly never stops the turn (by design — keep that);
-- surface `unsubscribe` doesn't touch the warming schedule (correct for mere disconnects);
-- `POST /conversations/:id/cancel` is DEFERRED in `transport-contract`;
-- programmatically invoking `cache-warming/toggle` is unsuitable: it FLIPS with no payload, so
- it's racy as an explicit "disable" (and doesn't abort generation).
-
-Preferred shape (backend's call): a single explicit `POST /conversations/:id/close` (or WS
-message) that does both, OR un-defer `/cancel` + accept an optional explicit boolean payload on
-`cache-warming/toggle`. Whatever ships, the FE wires it into its tab-close path. Note the
-asymmetry the user wants: browser/socket disconnect ⇒ warming continues; explicit tab close ⇒
-warming + generation stop.
-
-### CR-4d (minor) — echo `conversationId` on the initial `surface` message
-Per the `ui-contract` doc comment on `SurfaceMessage` (see deviation above).
-
-## FE-side follow-ups (ours, queued behind the above)
-- Harden the countdown display: a past `nextWarmAt` renders as "waiting…" instead of a stuck
- "0s" (cosmetic guard; CR-4b is the real fix).
-- On CR-4c shipping: call the close affordance from `store.closeTab()`; re-pin + re-mirror the
- contract; extend `scripts/probe-cache-warming.ts` to verify default-off + post-warm countdown.
diff --git a/backend-handoff-chat-limit.md b/backend-handoff-chat-limit.md
deleted file mode 100644
index da20583..0000000
--- a/backend-handoff-chat-limit.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Backend handoff — CR-5: history windowing for the FE chat limit (courier doc)
-
-> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user.
-> Companion to the living `backend-handoff.md` (§2 CR-5). 2026-06-12.
-
-## Context — what the FE is building (no backend blocker)
-
-The FE is adding a **chat limit**: in very long conversations the transcript unloads old
-chunks from memory/DOM so the browser stays fast. Policy (already decided with the user):
-
-- Limit `L` counts **chunks** (default 256, localStorage-configurable).
-- When the loaded count exceeds `L`, the FE unloads the oldest `ceil(L/4)` chunks in ONE
- bulk pass (e.g. `L=100`: at 101 chunks it unloads 25 → 76 remain). Bulk-on-threshold —
- NOT one-per-delta like old Dispatch — to kill the scroll-jump-per-step failure mode.
-- A fresh page load shows only the newest `floor(0.75 × L)` chunks (192 for the default).
-- A "Show earlier messages" affordance pages older history back in (today: from the FE's
- IndexedDB cache, which still holds it).
-
-**This works TODAY with no backend change** — the FE fetches everything and windows in
-memory. The ask below makes the *fresh-browser* case cheap: with an empty IndexedDB cache,
-`GET /conversations/:id?sinceSeq=0` currently returns the ENTIRE conversation, so a
-10k-chunk chat downloads + parses megabytes only for the FE to display 192 chunks.
-
-## The ask (additive, `transport-contract` bump)
-
-Extend `GET /conversations/:id` with two OPTIONAL query params:
-
-1. **`limit=<n>`** — return only the **newest** `n` chunks of the selection (still
- ascending seq order in the response). Selection semantics otherwise unchanged
- (`seq > sinceSeq`).
- - **If the selection has ≤ `n` chunks, return everything** — the FE will routinely send
- a largish number (e.g. `limit=192`) against short conversations and expects the
- normal full response (that flow must stay cheap and exact).
- - `limit` absent → exactly today's behavior (full selection). Existing FE versions keep
- working unchanged.
-2. **`beforeSeq=<s>`** — restrict the selection to `seq < s` (combined with `limit`: the
- newest `n` chunks below `s`, ascending). This is the "Show earlier messages" page-in
- path for history the FE's local cache doesn't have (e.g. a fresh browser that
- initial-loaded with `limit`). `beforeSeq` + `sinceSeq` together = `sinceSeq < seq < s`
- (we only ever send one of them, but defined semantics beat undefined).
-
-And one additive response field on `ConversationHistoryResponse`:
-
-3. **`earliestSeq?: number`** (or `hasOlder: boolean` — your pick, flag your choice in the
- reply) — the conversation's overall lowest seq (or whether chunks exist below the
- returned window). The FE needs to know whether to OFFER "Show earlier messages" when
- its local cache is exhausted. Without it the FE can only guess (seq 1 = start works if
- seqs are guaranteed to start at 1 and be gap-free — if you'd rather just CONFIRM that
- invariant in writing, the FE can derive `hasOlder` from `chunks[0].seq > 1` and we skip
- the new field entirely; cheapest option, totally fine).
-
-## How the FE will consume it
-
-- Fresh load (empty cache): `GET /conversations/:id?sinceSeq=0&limit=<floor(0.75×L)>`.
-- Incremental tail sync (cache warm): unchanged `?sinceSeq=<maxCachedSeq>` (no limit — the
- tail since last sync is small by construction).
-- Show-earlier beyond local cache: `GET /conversations/:id?beforeSeq=<oldestLoadedSeq>&limit=<ceil(L/4)>`.
-- The FE's IndexedDB cache is seq-keyed + dedup-by-seq and already tolerates a
- non-contiguous prefix (a windowed suffix), so no cache-format change is needed FE-side.
-
-## Priority / sequencing
-
-Not a blocker — the FE ships the limit feature against the current contract (full fetch +
-in-memory windowing) and lights up the `limit`/`beforeSeq` params when you ship. Ship
-whenever convenient; please bump `transport-contract` and note the params in the reply
-handoff so the FE re-pins + re-mirrors.
diff --git a/backend-handoff-cwd-lsp.md b/backend-handoff-cwd-lsp.md
deleted file mode 100644
index d896deb..0000000
--- a/backend-handoff-cwd-lsp.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# FE handoff — cwd + LSP consumed; please VERIFY these backend behaviors
-
-> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
-> Focused courier doc (the living seam is `backend-handoff.md`). `lsp references` does not span the
-> two repos, so this is the cross-repo channel. Re: your `frontend-lsp-cwd-handoff.md`
-
-## What the FE built (so you know what's now exercising your endpoints)
-
-A new `workspace` feature consumes the cwd + LSP endpoints:
-- **cwd field** in the Model sidebar panel — `GET /conversations/:id/cwd` to seed, `PUT` to set.
-- **"Language Servers" sidebar view** — `GET /conversations/:id/lsp`, rendering each `LspServerInfo`
- as a `connected`/`starting`/`error`/`not-started` badge (spinner while transient, `error` text shown
- inline), with a manual Refresh. Loaded on mount and whenever the cwd changes.
-- The FE **normalizes the untyped LSP body** at the network seam (a missing/partial `servers` ⇒ `[]`),
- so a malformed response can't crash the UI.
-
-**Key design point that drives the asks below:** the FE lets the user set the cwd / view LSP **for a
-DRAFT conversation that has not sent any message yet.** A draft already has a stable, client-minted
-`conversationId` (the FE mints ids and sends them on `chat.send`); that same id is reused when the
-draft is promoted on first send. So a cwd set on a draft must carry into its first real turn.
-
-## Please CONFIRM / ensure correct
-
-1. **Unseen-id graceful reads (CRITICAL).** For a `conversationId` the backend has **never seen**
- (a fresh draft id — no `/chat`, no prior write):
- - `GET /conversations/:id/cwd` ⇒ **`200 { conversationId, cwd: null }`** (not 404/500).
- - `GET /conversations/:id/lsp` ⇒ **`200 { conversationId, cwd: null, servers: [] }`** (not 404/500).
- The FE polls both for drafts on app load / panel mount. If an unseen id errors, the draft
- Language-Servers panel shows a spurious error and the cwd field can't seed. Your handoff says
- "cwd is null until set," which implies this — please confirm it holds for a **brand-new** id.
-
-2. **`PUT /conversations/:id/cwd` on an unseen/draft id persists it.** A `PUT` with a client-minted id
- that has had no `/chat` yet should `200` and persist, keyed purely by id (the conversation need not
- "exist" yet). Confirm the cwd store doesn't require a prior turn / row.
-
-3. **cwd defaulting carries the draft cwd into turn 1.** Sequence: FE `PUT /conversations/D/cwd {cwd}`
- → then `chat.send`/`POST /chat` with `conversationId: D` and **no `cwd` field**. Per your handoff's
- "cwd defaulting," that turn must run in the persisted `D` cwd. Confirm this works when the cwd PUT is
- the FIRST thing that ever touched conversation `D`.
-
-4. **CORS preflight for `PUT`.** The handoff says CORS now allows `PUT`; please confirm the browser
- **preflight** (`OPTIONS /conversations/:id/cwd` with `Access-Control-Request-Method: PUT`) is
- answered, not just the `PUT` itself — otherwise the browser blocks the request before it's sent.
-
-5. **No spawn when cwd is null.** `GET /lsp` with `cwd: null` returns `servers: []` **without** spawning
- any language server (so draft polling never spawns). Confirm the lazy spawn only happens once a cwd
- is set.
-
-6. **Error body shape.** On a 4xx/5xx the FE reads `{ error: string }` (e.g. the `400` from an
- empty-cwd `PUT`). Confirm error responses use that shape so the FE surfaces the reason.
-
-## FE behavior notes (no action needed — FYI)
-- LSP status is **HTTP-polled** (panel mount / cwd change / manual Refresh). A WS/surface push for LSP
- status would let the FE drop the manual refresh and reflect live state flips — listed as a future ask
- in `backend-handoff.md` §3, NOT requested now.
-- The FE shows the `LspServerInfo.error` text verbatim (e.g. `ENOENT ... posix_spawn`), per your
- operational note about binaries needing to be on the daemon PATH.
-
-**None of these are blocking** — they are correctness confirmations for the draft path the FE now
-exercises. If (1) or (3) don't hold as assumed, that's the one thing that would need a backend change.
diff --git a/backend-handoff-workspaces-reply.md b/backend-handoff-workspaces-reply.md
deleted file mode 100644
index 2ddfaf5..0000000
--- a/backend-handoff-workspaces-reply.md
+++ /dev/null
@@ -1,216 +0,0 @@
-# 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
deleted file mode 100644
index dc27c0e..0000000
--- a/backend-handoff-workspaces.md
+++ /dev/null
@@ -1,157 +0,0 @@
-# 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
deleted file mode 100644
index 4e3c6df..0000000
--- a/backend-handoff.md
+++ /dev/null
@@ -1,1209 +0,0 @@
-# Backend handoff — LIVING doc (FE ⇄ backend, couriered by the user)
-
-> **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:** `../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-27 (FE-only slice: **workspace-active indicator** — loading-dots on
-workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued
-conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a
-`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at
-`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend /
-contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_
-_Last updated: 2026-06-27 (§2j-update-3 — concurrency-fixes: configurable + persisted per-provider release
-cooldown, adaptive headroom auto-reduce banner, usage gate. ADDITIVE to `[email protected]`, NO version
-bump — `ConcurrencyStatusEntry` gained `cooldownMs`/`autoReduced`/`autoReducedFrom?`/`notice?`; NEW
-`ConcurrencyCooldownResponse`/`SetConcurrencyCooldownRequest` + `GET`/`PUT /concurrency/cooldown/:providerId`.
-FE re-synced the `file:` dep + re-mirrored `.dispatch/transport-contract.reference.md`; built the cooldown
-view-model + inline-edit row, a dismissible auto-reduce banner with "Restore to N", store `getConcurrencyCooldown`/
-`setConcurrencyCooldown`, + 49 new tests. typecheck 0/0, 1050 tests green (run TWICE), biome clean, build OK.
-Worktree env note: an untracked `dispatch-backend → backend` symlink was created in the worktree parent so the
-canonical `file:../dispatch-backend/...` paths resolve — NOT committed.) Prior: §2j (vision consult_vision + image
-storage)._
-_Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-star` shipped `Workspace.starred: boolean`
-(additive to `[email protected]`, NO version bump) + `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss;
-return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`,
-`store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆).
-Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_
-**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
-_Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE
-re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests
-green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._
-_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP
-paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp,
-not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic
-echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchanged. typecheck 0/0, 959 tests green
-(+11), biome clean, build OK. §2i unchanged.)_
-**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). The SSH-divergence
-(§2d) is RESOLVED. CR-13 (`"queued"` ConversationStatus) is RESOLVED.
-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]` on `dev` (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]`**.
-
-| Package | Used for |
-|---|---|
-| `@dispatch/ui-contract` | surfaces + surface WS protocol |
-| `@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`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`,+`starred`)/`Computer`/`ComputerEntry` (SSH handoff #1) |
-| `@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`) + concurrency (`ConcurrencyLimitsResponse`/`SetConcurrencyLimitRequest`/`ConcurrencyLimitResponse`/`ConcurrencyStatusEntry`/`ConcurrencyStatusResponse`) + 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`/`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; carries `workspaceId`) ·
-WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`) ·
-`GET /concurrency/limits` · `GET`/`PUT`/`DELETE /concurrency/limits/:providerId` · `GET /concurrency/status`
-(per-provider in-flight caps + oldest-agent-first queueing + 429-pause backoff; the `concurrency` extension — when not
-loaded the list + status endpoints return empty arrays, the single/PUT/DELETE return `503`).
-`GET`/`PUT /concurrency/cooldown/:providerId` (per-slot release cooldown — configurable + persisted; 0 = no cooldown;
-`GET` 404 when the provider has no concurrency config at all, `PUT` 400 on a non-negative-int body — see §2j-update-3).
-
-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)
-
-- **`chat.send` must omit `cwd`** (send `undefined`), never `cwd:""`/`cwd:null`. The `/chat` `cwd`
- field treats any non-`undefined` value as "provided". Verified safe: `chat/store.svelte.ts` builds
- `chat.send` with only `type`/`conversationId`/`message`/`model` — no `cwd` field.
-- **Per-conversation seqs are 1-based, monotonic, gap-free** (CR-5 contractual guarantee on
- `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-13 — Per-conversation `"queued"` status for the concurrency queue → **RESOLVED ✅ (backend shipped; FE consumed + tested)**
-
-Small UX ask: when a conversation's request is WAITING in the per-provider concurrency queue (not yet
-generating tokens), the FE shows the loading **RING** (spinner) on that tab + in the composer corner,
-instead of the loading **DOTS** (dots = actively generating).
-
-**Backend (shipped — additive to `[email protected]`, NO version bump):** `ConversationStatus` widened to
-`"active" | "queued" | "idle" | "closed"`. When the concurrency manager CANNOT grant a slot
-immediately (at limit or paused), `onQueued` fires → the orchestrator broadcasts
-`conversation.statusChanged` with `status: "queued"` (carrying `workspaceId`, same shape as before).
-`"queued"` is BROADCAST ONLY — it is NOT persisted (the persisted status stays `"active"`; on restart
-conversations show `"active"`, never stuck in `"queued"`). When the slot is granted (`acquire`
-resolves), `onAcquired` fires → broadcasts `"active"` again. The existing `"idle"` on turn seal is
-unchanged. A request that gets a slot immediately never emits `"queued"`. Also: `GET /conversations?status=queued`
-now works ("queued" added to the valid status filter set).
-
-**FE (DONE + verified):**
-- Re-synced the `@dispatch/wire` `file:` dep (`bun install`); `node_modules/@dispatch/wire/dist` now has
- `"queued"` in `ConversationStatus`. Re-mirrored `.dispatch/wire.reference.md` (widened the type +
- header delta note).
-- WS parser (`src/adapters/ws/logic.ts`): accepts `"queued"` in the `conversation.statusChanged`
- status set (was hard-coded to `active/idle/closed`).
-- Store handler (`onConversationStatusChanged`): `"queued"` updates the status map (drives the tab
- spinner) AND opens a tab for a new cross-device queued conversation (like `"active"`; `"idle"`
- never opens). `closed` still removes the tab.
-- `TabList.svelte`: `status === "queued"` → loading-**ring** (`loading-spinner`, `aria-label="Queued"`,
- `title="Waiting for a concurrency slot"`); `status === "active"` → loading-**dots** (unchanged); no
- status → no spinner.
-- `Composer.svelte`: `status` type widened to `ComposerStatus = "idle" | "running" | "queued" | "error"`
- (exported via `features/chat/index.ts`). `"queued"` → a loading-ring status icon (`aria-label="Queued"`)
- + placeholder "Queued for a slot…". `"queued"` behaves like `"running"` for the send button
- (`inFlight = running || queued` → steer/stop) — the turn is in flight, just waiting for a slot.
-- `App.svelte`: `composerStatus` derived (`error > queued > running > idle`) — `conversationStatus(id)
- === "queued"` wins over `generating` so the corner shows a ring during the wait (`turn-start` fires
- before the slot is granted, so `generating` is already `true` while `conversationStatus === "queued"`;
- the explicit queued check is what distinguishes them).
-- Tests: WS parser accepts `"queued"`; store handler sets the status + opens a cross-device tab +
- transitions `queued → active → idle`; TabList renders a ring for `"queued"` + dots for `"active"`.
-
-**Verification:** typecheck 0/0, **925 tests green** (run TWICE — no cross-test pollution; the store
-handler tests feed real WS frames through the parser), biome clean, build OK. Live probe NOT run (the
-backend is the user's process; never booted headless). To confirm end-to-end: set a provider's
-concurrency limit to 1, start 2 turns on that provider, watch the second tab show a ring ("Queued")
-until the first finishes, then flip to dots ("active").
-
-### 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.
-
-### Workspace starring (concurrency priority) → **CONSUMED ✅ (backend shipped `feature/workspace-star`; FE built + green)**
-
-**Backend contract change (additive to `[email protected]`, NO version bump):** `Workspace` gains a required
-`starred: boolean` (defaults `false` on creation). A starred workspace's agents receive PRIORITY in the
-concurrency limiter queue — they jump ahead of agents from non-starred workspaces (oldest-agent-first
-within each group); takes effect immediately for already-queued agents (backend-owned, via
-`setWorkspaceStarred` + `concurrencyService.notifyWorkspaceStarred`). Two dedicated endpoints (no body;
-both create-on-miss; return the updated `Workspace`):
-- `PUT /workspaces/:id/star` → star (400 on invalid slug; 200 `Workspace{starred:true}`).
-- `DELETE /workspaces/:id/star` → unstar (400 on invalid slug; 200 `Workspace{starred:false}`).
-
-`PUT /workspaces/:id` does NOT accept a `starred` field (the dedicated endpoints are the only path). No
-new `transport-contract` request/response types — both reuse `WorkspaceResponse`.
-
-**FE consumed (all green — typecheck 0/0, 1045 tests, biome clean, build OK):**
-- `adapter/http.ts`: `star(id)`/`unstar(id)` → `WorkspaceResult<Workspace>` (PUT/DELETE `/workspaces/:id/star`, no body).
-- `logic/view-model.ts`: pure `sortWorkspaces` (starred-first, then `lastActivityAt` desc, stable) + pure
- `applyStarred` (the optimistic apply/revert transformation). Both unit-tested.
-- `store.svelte.ts`: `setStarred(id, starred)` — optimistic flip (the `$derived` sorted list re-orders
- reactively) with error revert; no full refresh on success (avoids flicker). Re-mirrored `.dispatch/wire.reference.md`.
-- `ui/WorkspaceCard.svelte`: a star toggle button (filled gold ★ when starred, outline ☆ when not; spinner
- while in flight; `aria-pressed`/`aria-label`; tooltip notes concurrency priority). Clicking calls
- `store.setStarred(ws.id, !ws.starred)`.
-- Tests: http star/unstar (5), view-model sort+applyStarred (12), store optimistic+revert+re-sort (6), WorkspaceCard star button (4).
-
-**No open asks for the backend.** Live probe of the star endpoints against a running backend is the only
-remaining human step (the `scripts/live-probe.ts` does not cover workspace endpoints; a manual `curl` or a
-click in the home view confirms the round-trip).
-
-### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending)
-
-The backend now persists chunks **incrementally at step boundaries** during generation:
-1. Turn starts → user message is `append`ed immediately (gets seq).
-2. Each step completes → step's messages are `append`ed immediately (get seq).
-3. Turn seals → `turn-sealed` emitted (no batch append needed — already persisted).
-
-`GET /conversations/:id?sinceSeq=N` returns committed, seq'd chunks **during generation**. The
-FE's existing `syncTail` already polls this — it will find new chunks as each step completes. No
-wire/transport-contract change needed (`StoredChunk` already has `seq`; `AgentEvent` types unchanged).
-
-**FE adoption: NOT pursuing syncTail-during-generation.** Investigation revealed
-the kernel emits `step-complete` (line 360 of `run-turn.ts`) BEFORE calling
-`onStepComplete` (line 542) — the step's chunks are persisted only AFTER tool
-results come back, not when `step-complete` fires. So `syncTail` triggered by
-`step-complete` finds nothing. Moving the emission after `onStepComplete` would
-be a kernel change.
-
-Instead, the FE now trims provisional chunks directly in `trimTranscript` when
-committed is exhausted — no `syncTail` needed. Dropped provisional chunks are
-lost temporarily (no "Show earlier" for them) but come back as committed when
-the turn seals and `syncTail` fetches everything.
-
----
-
-### Resolved CRs (for reference)
-
-| 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]` |
-| CR-4 | cache-warming lifecycle (default OFF, future `nextWarmAt`, `POST /close`) | ✅ `[email protected]` |
-| CR-5 | history windowing (`?limit=`, `?beforeSeq=`, 1-based gap-free seqs) | ✅ `[email protected]` / `[email protected]` |
-| CR-6 | Assign seq during generation (incremental persist at step boundaries) | ✅ shipped; FE adoption pending |
-
----
-
-## 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:
-`../backend/frontend-mcp-status-handoff.md`; shape/behavior details:
-`../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).
-
----
-
-## 2d. SSH support — handoff #1 (wire types) → **CONSUMED ✅ (provider-retry divergence RESOLVED)**
-
-The first of a few incremental SSH handoffs. The backend's `@dispatch/wire` (pinned `file:` dep) gained SSH-computer
-types — **additive to `[email protected]`, NO version bump** (same pattern as `provider-retry`). The full HTTP API surface
-(computer endpoints, `chat.send computerId`) comes in a LATER handoff; this one is wire-types only.
-
-**New wire types (consumed + re-mirrored):**
-- `Workspace` gained a REQUIRED `defaultComputerId: string | null` (null = local / no SSH; the computer analog of
- `defaultCwd`). Resolution is SERVER-owned (per-conv `computerId` → `workspace.defaultComputerId` → `null`/local).
-- `Computer` — a read-only view of a discovered `~/.ssh/config` `Host` target:
- `{ alias, hostName, port, user, identityFile, knownHost }`. `alias` IS the `computerId` users select. NOT an
- editable entity (no CRUD store — the user edits `~/.ssh/config` to add one).
-- `ComputerEntry extends Computer` — adds `usageCount` (for `GET /computers`).
-
-**FE (DONE):**
-- Re-synced the `@dispatch/wire` `file:` dep (worktree layout note below) so the new types resolve.
-- Re-mirrored `.dispatch/wire.reference.md`: added `Workspace.defaultComputerId`, the `Computer`/`ComputerEntry`
- section, and a header delta note.
-- Fixed the 2 `Workspace`/`WorkspaceEntry` test literals that broke (the handoff's expected break):
- `src/features/workspaces/ui/WorkspaceCard.test.ts` (`fakeEntry`) and
- `src/features/workspaces/logic/view-model.test.ts` (`ws` factory) — both now supply `defaultComputerId: null`.
- (The conformance test's `provider-retry` case is the unrelated divergence below — NOT a `defaultComputerId` site.)
-- Added `computer` / `computerId` to `GLOSSARY.md` (backend-canonical, adopted verbatim).
-
-**NOT started (correctly deferred):** the `computer` feature folder, the per-conversation + workspace-default
-selectors, the connection-status badge, and `chat.send computerId` — all wait on the later HTTP-API handoff.
-
-### `provider-retry` divergence → **RESOLVED ✅ (backend merge `de022ce`)**
-
-**Was:** the backend `feature/ssh-support` branch (cut from `8a74335`) was MISSING `TurnProviderRetryEvent` /
-`provider-retry` (on `dev`), causing 11 FE typecheck errors. **Resolved:** backend merged `dev` into
-`feature/ssh-support` (merge commit `de022ce`, in the shared `../backend` worktree); `packages/wire/dist/index.d.ts`
-now exports `TurnProviderRetryEvent` (AgentEvent union line 231 + interface line 384) ALONGSIDE the SSH types
-(`Workspace.defaultComputerId`, `Computer`). Backend post-merge: `tsc -b` EXIT 0, biome clean, 1730 vitest pass.
-The auto-merge was clean — `computerId` threading + retry-with-backoff coexist.
-
-**FE (DONE, zero code changes):** re-synced both `file:` deps (`bun install`); `node_modules/@dispatch/wire` +
-`@dispatch/transport-contract` both resolve `TurnProviderRetryEvent` (the transport-contract re-export confirmed).
-`bun run typecheck` is now **GREEN — 0 errors, 0 warnings** (the 11 cleared with NO FE code changes; the
-`provider-retry` consumption was already complete + tested). Full suite: 795/795 tests, biome clean, build OK.
-
-### Worktree environment note (not a contract change)
-This worktree lays the repos out as `…/worktrees/ssh-support/{backend,frontend}`, but `package.json`'s canonical
-`file:` paths point at `../backend` (correct for the main `dispatch/{dispatch-backend,dispatch-web}` layout).
-To keep `package.json` canonical (no worktree-specific hack committed), a symlink `../backend → ../backend`
-was created in the worktree parent (untracked, outside the repo), then `bun install` re-synced `node_modules/@dispatch/*`.
-The backend wire `dist/` was already built + current (has the new types); no backend edit was made.
-
----
-
-## 2e. SSH support — handoff #2 (full computer HTTP API) → **CONSUMED ✅ (FE built; fully green post §2d merge)**
-
-The backend shipped the full SSH computer HTTP/WS API (transport-contract types stable; the `ssh` extension that
-provides the ComputerService is the last backend wave — until it lands, `GET /computers` returns `[]` and statuses
-return `disconnected`, which the FE renders gracefully). The FE mirrors the existing `cwd`/`workspaces` UI.
-
-**New transport-contract types consumed (additive to `[email protected]`, NO version bump):** `ComputerListResponse`
-(`GET /computers`), `ComputerResponse`, `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse`
-(`POST /computers/:alias/test`), `SetConversationComputerRequest` + `ConversationComputerResponse`
-(`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest`
-(`PUT /workspaces/:id/default-computer`), and `computerId?: string` on `ChatRequest`/`ChatSendMessage`/`QueueRequest`.
-`Computer`/`ComputerEntry` are `@dispatch/wire` (handoff #1). Re-mirrored `.dispatch/transport-contract.reference.md`
-(added the Computers section + `ChatRequest.computerId`).
-
-**FE (DONE — mirrors cwd-lsp's consumer-defines-port pattern):**
-- New feature library `src/features/computer/`: pure `logic/view-model.ts` (`viewComputer`/`viewComputerStatus`/
- `viewTestResult`/`summarizeComputers`/`formatHost`/`knownHostLabel` + state→badge mapping for the 4
- `ComputerStatusResponse.state`s + the `SaveComputer`/`LoadComputerStatus`/`TestComputer`/`LoadComputers` ports) — 20
- view-model tests green; `ui/ComputerField.svelte` (per-conversation selector: dropdown + connection-status badge +
- Test-connection, polling the selected alias) + `ui/ComputerSelect.svelte` (a reusable Local/computers dropdown, shared
- with the workspace default-computer control); `index.ts` (`ComputerField`/`ComputerSelect`/`manifest`/types).
-- `AppStore` (`src/app/store.svelte.ts`): `computerId` reactive state + `refreshComputer()` (parallel to `refreshCwd`,
- called at every focus site: boot, workspace switch, draft→tab, newDraft, selectTab, removeTabLocally) +
- `setComputer(computerId: string | null)` (`PUT /conversations/:id/computer`, null = clear) + a global `computers`
- catalog (`GET /computers` on boot, like `models`) + `computerStatus(alias)` + `testComputer(alias)`. New result types
- `ComputerResult`/`ComputerStatusResult`/`TestComputerResult`. `chat.send` UNCHANGED (computer resolved server-side
- from the persisted per-conversation value, exactly like cwd).
-- `src/app/App.svelte`: `ComputerField` mounted in the "Model" sidebar view next to `CwdField`, keyed on
- `currentConversationId`; adapted ports (`saveComputer`/`loadComputerStatus`/`testComputer`) wrap the store.
-- Workspaces: `setDefaultComputer` added to `WorkspaceHttp` + `WorkspaceStore` (`PUT /workspaces/:id/default-computer`);
- a default-computer selector (reusing `ComputerSelect`) added to `WorkspaceCard.svelte` next to the default-cwd control;
- the router (`src/App.svelte`) passes `store.computers` through `WorkspacesHome` → `WorkspaceCard`.
-
-**Transparency invariant (held):** the computer is USER-facing only — it is a tool-execution target forwarded to tools
-and NEVER part of the model prompt (so it does not affect prompt caching); the agent never sees it. Documented in the
-feature's pure core + surfaced in the `ComputerField` helper text.
-
-**NOT done (correctly deferred):** a per-send `computerId` override (the MVP UI doesn't expose it; persisted per-conversation
-suffices). No `chat.send` change.
-
-**Verification:** 795/795 tests green (50 files; +20 computer view-model); biome clean; `vite build` succeeds. `svelte-check`
-reports **0 errors from the computer feature**. (The pre-existing §2d `provider-retry` divergence — 11 errors — is
-now RESOLVED via the backend `dev`→`feature/ssh-support` merge `de022ce`; `bun run typecheck` is fully GREEN.)
-Live probe NOT run (the backend `ssh` extension isn't live yet → `GET /computers` returns `[]` end-to-end; a live probe +
-human confirm of the dropdown/badge/test should run once `ssh` is wired + the `provider-retry` divergence is merged).
-
----
-
-## 2f. Heartbeat (workspace autonomous-agent loop) → **CONSUMED ✅ (backend shipped; FE built)**
-
-The backend shipped a workspace-scoped **heartbeat** — an autonomous agent loop that periodically runs a turn in a
-dedicated conversation using a configured system prompt, task prompt, model, reasoning effort, and interval. The FE
-exposes the config, run history, and a per-run live chat in a new sidebar **Heartbeat** view (branch `feature/heartbeat`).
-
-**Backend API (plain REST — NOT a transport-contract type):**
-- `GET /workspaces/:id/heartbeat` → `{ enabled, systemPrompt, taskPrompt, intervalMinutes, model, reasoningEffort }`
-- `PUT /workspaces/:id/heartbeat` (partial body) → updated config
-- `GET /workspaces/:id/heartbeat/runs` → `{ runs: [{ id, conversationId, triggeredAt, status }] }` (`status: running|completed|stopped`)
-- `POST /workspaces/:id/heartbeat/runs/:runId/stop` → `{ ok: true }`
-
-**Contract note (important):** the heartbeat shapes are NOT in `@dispatch/transport-contract` / `@dispatch/wire`
-(verified: no `heartbeat` symbol in either `dist/`). So the FE owns the types locally in `src/features/heartbeat/logic/types.ts`
-(consumer-defines-port, mirroring the `mcp`/`computer` result-type pattern) and coerces the untyped JSON at the network
-seam via pure `normalizeHeartbeatConfig`/`normalizeHeartbeatRuns` (a malformed/partial response can never crash the
-renderer). **If the backend later promotes these to a shared contract package, swap the local types for the imports +
-re-mirror `.dispatch/*.reference.md`.** No contract version bump on the FE side (no `file:` dep re-pin needed).
-
-**FE (DONE + verified):**
-- New feature library `src/features/heartbeat/`: pure `logic/view-model.ts` (`viewRun`/`viewRuns`/`badgeForStatus`/
- `statusLabelFor`/`formatRunTime`/`relativeLabel`/config-form helpers `formFromConfig`/`patchFromForm`/`formDiffers`/
- `normalizeInterval` (1–1440 clamp)/network normalizers + `effortOptions` re-exported from `features/chat`) — 35
- view-model tests green; `ui/HeartbeatView.svelte` (config panel: enable toggle (saves immediately), system + task
- prompt textareas, model dropdown, reasoning-effort dropdown, interval input, Save button with `hasChanges` guard;
- scrolling runs list polling every 4s with a spinner when running + per-row Stop) + `ui/RunModal.svelte` (fullscreen
- modal that reuses `features/chat`'s `ChatView` to render the run's conversation; live-streams via the store's
- `watchConversation`/`chat.subscribe` while `generating`, with a Stop button → `POST .../runs/:runId/stop`);
- `index.ts` (`HeartbeatView`/`RunModal`/`manifest`/types). The reasoning-effort ladder is REUSED from `features/chat`
- (sanctioned cross-feature import through its public exports) — no drift.
-- `AppStore` (`src/app/store.svelte.ts`): `heartbeatConfig`/`setHeartbeatConfig`/`heartbeatRuns`/`stopHeartbeatRun`
- (workspace-scoped; read `activeWorkspaceId` with `untrack`) + **`watchConversation`/`unwatchConversation`** — a new
- "watch" mechanism for the modal: reuses an open tab's `ChatStore` (already subscribed) or creates an EPHEMERAL watch
- store in a new `watchStores` map (separate from tabs — never opens a tab) + subscribes via `chat.subscribe` + loads
- history; deltas route to it (the `handleChatMessage` hot path now also checks `watchStores`); `onReopen` re-subscribes
- + resyncs watch stores; `dispose` disposes them. `unwatchConversation` is a no-op for a tab conversation (it keeps its
- store + stream) — only the ephemeral watch store is disposed + unsubscribed.
-- Wired into `src/app/App.svelte`: `"heartbeat"` view kind (in `viewKinds`), `heartbeatManifest` in `loadedModules`,
- thin adapter functions, the `viewContent` snippet branch, + `{#key heartbeatRun.id}` RunModal render when a run is
- selected.
-- Store tests (`src/app/store.test.ts`): +7 — config load/PUT-merge/error, runs load, stop POST, watch subscribe +
- live-delta routing, tab-reuse (no extra subscribe) + unwatch no-op for a tab.
-- Also fixed a PRE-EXISTING `WorkspaceCard.test.ts` typo (`onNavigate` shorthand used before declaration →
- `onNavigate: vi.fn()`) that was red on the branch HEAD independent of heartbeat.
-
-**Verification:** 837/837 tests green (run TWICE — no cross-test pollution; the watch stores are plain `Map`s, not
-shared globals, but the methodology's double-run is honored); `svelte-check` 0 errors; biome clean; `vite build`
-succeeds. Live probe NOT run (the backend's heartbeat loop + endpoints were not reachable headless at verify time; the
-unit + store tests fully cover the data path + the WS routing seam). To confirm end-to-end: start the backend, open the
-Heartbeat sidebar view, toggle enable, edit+Save the config, watch a run appear + stream live in the modal, click Stop.
-
-**Vocabulary note (heads-up, not blocking):** `GLOSSARY.md` marks **"view"** as RESERVED (old-Dispatch sidebar
-affordance, future). The heartbeat UI follows the codebase's ESTABLISHED convention — sidebar panels are already called
-"views" pervasively (`viewKinds`, `ViewSidebar`, `viewContent`, "Model view"/"LSP view"/"Settings view"). The feature
-module itself is named `heartbeat` (a feature module). No new term was coined. If the reserved-"view" cleanup happens
-later, the heartbeat view kind renames in lockstep with the rest.
-
----
-
-## 2g. Heartbeat follow-up UI — prompt editor modal + hours/minutes timer → **FE BUILT; 1 BACKEND ASK**
-
-A follow-up to §2f (branch `feature/heartbeat`, uncommitted-to-this-handoff commit). Two UI changes on the heartbeat
-config panel, each analyzed below for backend impact. **One needs a backend change (variable resolution in the
-heartbeat prompts); the other needs none.**
-
-### Change A — Prompt editor modal (replaces the two textareas) → **1 BACKEND ASK (CR-HB-1)**
-
-The config panel's two inline textareas (system prompt + task prompt) are replaced by a single "Edit prompts" button
-that opens a full-width modal (`src/features/heartbeat/ui/PromptEditor.svelte`):
-- LEFT side: two stacked text editors (top = system prompt, bottom = task prompt).
-- RIGHT side: the variable palette (grouped by type, same `[type:name]` tag insertion as the global system-prompt
- builder). Clicking a variable inserts `[type:name]` at the cursor of whichever textarea is focused.
-- Save persists both prompts via the existing `PUT /workspaces/:id/heartbeat` with `{ systemPrompt, taskPrompt }`
- (a partial patch — no new endpoint, no data-shape change).
-
-**What the FE reuses (NO new endpoint needed):**
-- The variable palette is sourced from the EXISTING global `GET /system-prompt/variables` endpoint — the SAME one the
- global System Prompt builder uses. The heartbeat feature imports the pure helpers (`buildTag`, `groupVariables`,
- `insertTag`, `isDynamicVariable`) from `features/system-prompt` (its public `index.ts` — `isDynamicVariable` was added
- to that export in this slice, an additive cross-unit seam change). So there is **no new variable source or endpoint**;
- the heartbeat prompt editor offers the exact same variable tags the global system prompt does.
-- The persisted strings carry literal `[type:name]` placeholders (plain text), exactly like the global template.
-
-**CR-HB-1 — Resolve `[type:name]` variables in the heartbeat system/task prompts → ASK (needs backend confirmation + likely implementation):**
-
-The global system-prompt template is resolved by the backend: `[type:name]` placeholders (e.g. `[system:os]`,
-`[system:date]`, `[file:path]`, `[if system:wsl]…`) are substituted with their resolved values at construction time
-(once per conversation at first turn, then persisted for prompt-cache safety). **The critical question: does the
-backend apply this SAME variable resolution to the heartbeat's `systemPrompt` and `taskPrompt` fields?**
-
-- If YES (the heartbeat prompts already flow through the same resolver as the global template): **no backend change
- needed** — the FE just inserts the same `[type:name]` tags and the backend resolves them. Confirm + close.
-- If NO (the heartbeat prompts are inserted into the model turn as raw text, so `[system:os]` would reach the model
- literally as the string `[system:os]` rather than the resolved OS string): **the backend should resolve `[type:name]`
- placeholders in the heartbeat `systemPrompt` + `taskPrompt` using the SAME resolver/variable set as the global
- system prompt** (so a variable inserted in either place resolves identically). This is the expected gap — the heartbeat
- prompts are a NEW surface that predates the variable system, so they likely bypass the resolver.
-
- - Resolution timing: mirror the global template's behavior — resolve once when a heartbeat run's turn is constructed
- (NOT on every interval tick, to stay prompt-cache-safe; a stable resolved prompt keeps the cache warm across runs).
- If the heartbeat re-resolves per-run anyway (intervals may want fresh `[system:time]`), that's a backend product
- decision — the FE doesn't care WHEN it resolves, only THAT `[type:name]` becomes its value.
- - Variable set: the SAME catalog `GET /system-prompt/variables` returns (system/file/prompt/git groups). No
- heartbeat-specific variables are required for this slice.
- - No wire/transport-contract/ui-contract change needed — this is a backend behavior change (the prompt strings are
- still `string`; the FE is unaffected once the backend resolves them).
-
-**Optional future enhancement (NOT required now):** heartbeat-specific variables (e.g. `[heartbeat:runCount]`,
-`[heartbeat:lastResult]`, `[heartbeat:elapsed]`). The FE's prompt editor would offer these if `GET /system-prompt/variables`
-(or a new `GET /workspaces/:id/heartbeat/variables`) returned them. Defer until there's a product need.
-
-### Change B — Hours + minutes interval timer → **NO backend change needed**
-
-The interval input is split into two fields: an HOURS input (left) + a MINUTES input (right, 0–59). The conversion is
-entirely FE-side:
-- On load: the backend's single `intervalMinutes` is split into `{ hours, minutes }` via the pure `splitInterval`
- helper (`Math.floor(total/60)` hours, remainder minutes).
-- On save: the FE recombines via `joinInterval(hours, minutes)` → `hours*60 + minutes` (clamped to the 1–1440 range
- = 1 min–24 h) and sends a SINGLE `intervalMinutes` integer to `PUT /workspaces/:id/heartbeat`.
-
-So **the backend's `intervalMinutes` field is UNCHANGED** — still one integer of total minutes. The hours/minutes split
-is pure FE presentation. No endpoint, data-shape, or behavior change. (The existing clamp to 1–1440 also stands; a
-0h0m entry clamps to 1 minute — the FE guards this, and the backend's own validation should too, but that's pre-existing.)
-
-### FE summary (this slice)
-- `src/features/heartbeat/logic/view-model.ts`: `HeartbeatFormState` now carries `intervalHours` + `intervalMinutes`
- (0–59); new pure `splitInterval`/`joinInterval` round-trip helpers; `formFromConfig`/`patchFromForm`/`formDiffers`
- updated to split/recombine. +4 tests (split/join round-trip, form split, differs-after-edit).
-- `src/features/heartbeat/ui/PromptEditor.svelte` (new): two-pane modal (system+task editors left, variable palette
- right), focus-aware variable insertion, Save/Reset. Reuses `features/system-prompt`'s pure helpers.
-- `src/features/heartbeat/ui/HeartbeatView.svelte`: two textareas → "Edit prompts" button + modal; interval → hours+minutes inputs.
-- `src/features/system-prompt/index.ts`: added `isDynamicVariable` to the public exports (additive — needed by the
- heartbeat editor's dynamic `file:<path>` row).
-- `src/app/App.svelte`: passes `loadVariables={loadSystemPromptVariablesPrompt}` to `HeartbeatView`.
-
-**Verification:** typecheck 0/0, tests green, biome clean, build OK (see the commit). The variable-resolution behavior
-(CR-HB-1) can only be confirmed end-to-end against a running backend with variable-laden heartbeat prompts — a human
-should set `[system:os]` in a heartbeat prompt via the new editor, trigger a run, and confirm the resolved value (not the
-literal `[system:os]`) appears in the model's context / run transcript.
-
----
-
-## 2h. Heartbeat system-prompt default + reset button → **FE BUILT; 1 BACKEND ASK (CR-HB-2)**
-
-A follow-up to §2f/§2g (branch `feature/heartbeat`). Two UX changes on the heartbeat prompt editor:
-1. The system prompt now **defaults to the workspace's regular system prompt** (the global `GET /system-prompt`
- template — there is no per-workspace system prompt; `Workspace` has no `systemPrompt` field, and the system prompt
- is global, resolved once per conversation). When the heartbeat's `systemPrompt` is empty, the editor pre-fills the
- textarea with the global default so the user can see + tweak what will run — but a pre-filled default is NOT an
- explicit edit (no `hasChanges` until the user edits away from it).
-2. A **"Reset to default" button** reverts the system prompt to the global default (clearing any override → inherit).
-
-### Semantics: heartbeat `systemPrompt` is an OVERRIDE; empty = inherit the global default
-
-Following the codebase's established resolution-chain pattern (cwd / reasoning-effort / model / computer — all
-"persisted value OR fall back to a default; resolution is SERVER-owned"), the heartbeat's `systemPrompt` is now treated
-as an **override**:
-- `systemPrompt === ""` (empty) → **inherit** the global system prompt (the workspace's regular prompt).
-- non-empty → an explicit override.
-
-The FE never duplicates the global default into the heartbeat config: when the editor's text matches the default (or is
-empty), it persists `systemPrompt: ""` (inherit) — so a later change to the global default still flows through. A
-distinct edit persists the override verbatim. Pure helpers in `src/features/heartbeat/logic/view-model.ts`:
-`effectiveSystemPrompt(override, default)`, `isInheritingSystemPrompt(override)`,
-`persistedSystemPrompt(editable, default)` (+6 tests).
-
-### CR-HB-2 — Resolve an empty heartbeat `systemPrompt` to the global system prompt at run time → **ASK (backend behavior change)**
-
-The FE sends `systemPrompt: ""` to inherit. **The backend must resolve an empty heartbeat `systemPrompt` to the GLOBAL
-system prompt template (`GET /system-prompt`) at heartbeat-run construction time** — so a heartbeat with no override
-actually runs the workspace's regular system prompt (not an empty one).
-
-- **Resolution:** when building a heartbeat run's turn, if the heartbeat's persisted `systemPrompt` is `""`, substitute
- the global system prompt template (the same one `GET /system-prompt` returns / that conversations resolve). If
- non-empty, use the override as-is.
-- **Composes with CR-HB-1:** after resolving empty → global (CR-HB-2), the resulting prompt's `[type:name]` variable
- placeholders must still be resolved (CR-HB-1). So both apply, in order: (1) empty ⇒ global template, (2) resolve
- `[type:name]` placeholders in whichever prompt is in effect. (For an override, only step 2 applies.)
-- **Timing / cache-safety:** mirror the global template's behavior — resolve once per heartbeat run (or once + persist,
- whichever the heartbeat scheduler already does for prompt-cache safety). The FE doesn't care WHEN; only THAT empty
- becomes the global prompt.
-- **No wire/transport-contract/ui-contract change** — `systemPrompt` is still a `string`; empty = inherit. The FE is
- unaffected once the backend resolves it. **No new endpoint needed** — the FE already reads the global default via the
- existing `GET /system-prompt` (passed through as `loadDefaultPrompt`).
-
-### FE summary (this slice)
-- `src/features/heartbeat/logic/view-model.ts`: 3 pure inheritance helpers (+6 tests).
-- `src/features/heartbeat/ui/PromptEditor.svelte`: `loadDefaultPrompt` port (the global `GET /system-prompt`); loads
- the default on open + pre-fills the system textarea when inheriting; `hasChanges` diffs against the EFFECTIVE prompt
- (override or default) so a pre-filled default isn't an unsaved change; save persists `""` when the text matches the
- default (inherit); new "Reset to default" button + "Inheriting workspace default" badge + status hint.
-- `src/features/heartbeat/ui/HeartbeatView.svelte`: passes `loadDefaultPrompt` through; `onSaved` syncs the raw
- override (`""` = inherit) into the form.
-- `src/app/App.svelte`: passes `loadDefaultPrompt={loadSystemPromptPrompt}` (reuses the existing `store.loadSystemPrompt()` adapter) to `HeartbeatView`.
-
-**Verification:** typecheck 0/0, 849 tests green, biome clean, build OK. The inheritance RUNTIME behavior (CR-HB-2) can
-only be confirmed against a running backend: set the heartbeat system prompt to empty (or click "Reset to default" +
-Save), trigger a run, and confirm the run used the GLOBAL system prompt (not an empty one). Until CR-HB-2 ships, an
-empty heartbeat `systemPrompt` would run with no system prompt — the FE flags this as the known gap.
-
----
-
-## 2i. Heartbeat next-run countdown timer → **FE BUILT; 1 BACKEND ASK (CR-HB-3)**
-
-A follow-up to §2f/§2g/§2h (branch `feature/heartbeat`). The Heartbeat sidebar view now shows a live countdown to the
-next scheduled run ("Next run in 4m 32s") beneath the Enabled/Disabled status. The FE computes it from a server-
-authoritative next-run timestamp + a 1s ticking clock.
-
-### CR-HB-3 — `GET /workspaces/:id/heartbeat/next-run` → **ASK (new endpoint)**
-
-**New endpoint:**
-```
-GET /workspaces/:id/heartbeat/next-run
-```
-**Response shape:**
-```json
-{ "nextRunAt": "2026-06-25T14:05:00Z" }
-```
-or `null` when the heartbeat is **disabled** or **no run is scheduled**:
-```json
-{ "nextRunAt": null }
-```
-
-**Semantics:**
-- `nextRunAt` is the server-authoritative timestamp of the NEXT scheduled heartbeat run (the moment the scheduler will
- fire it), as an ISO 8601 string. It is DERIVED server-side from the scheduler state (the last run's start + the
- configured `intervalMinutes`, or the moment `enabled` was toggled on + `intervalMinutes` for the first run) — the FE
- cannot derive it accurately (it doesn't know when the last run fired relative to "now" + scheduling jitter, paused-
- while-running, etc.).
-- `null` when the heartbeat is disabled, or when no run is currently scheduled (e.g. a run is in flight and the next
- hasn't been queued yet — the FE then shows no countdown, not a fabricated one).
-- Recompute on each call (a cheap read of the scheduler's next-fire time). The FE polls it on the same 4s cadence as
- the runs list, so a run completing (→ next run scheduled) reflects within ~4s.
-
-**Why a dedicated endpoint (not a field on the config/runs response):** the user explicitly asked for "a timestamp
-endpoint." It's also the most efficient for polling (a lightweight read of just the next-fire time, vs. re-fetching the
-full config or runs). The FE polls it alongside the runs list every 4s.
-
-**No wire/transport-contract/ui-contract change** — the response is a plain JSON object (the heartbeat API is a plain
-REST surface, not a transport-contract type; the FE owns the `HeartbeatNextRunResult` type locally in
-`src/features/heartbeat/logic/types.ts` and coerces the untyped body at the network seam).
-
-### FE behavior (this slice) — works BEFORE the backend ships the endpoint
-
-- The store's `heartbeatNextRun()` calls `GET /workspaces/:id/heartbeat/next-run`; on **404/error** it returns
- `ok: false` (non-fatal). The FE then sets a `nextRunEndpointFailed` flag and **stops polling the endpoint** (no 404
- spam) and falls back to an APPROXIMATION: the latest run's `triggeredAt` + the configured `intervalMinutes`
- (`approximateNextRunEpoch`, pure + tested). So the countdown shows (approximate) immediately, and becomes ACCURATE
- once the backend ships CR-HB-3 (the FE prefers the server value when available).
-- A 1s ticking clock (`now` state) recomputes the countdown locally from `effectiveNextRun` (server value, else the
- approximation) — no per-second network churn. Pure `formatCountdown(remainingMs)` → "4m 32s" / "32s" / "1h 05m" /
- "due" (≤0) / "—" (unknown).
-- The countdown only renders when the heartbeat is **enabled** AND a next-run time is known (`effectiveNextRun !==
- null`); disabled → no countdown (just "Disabled").
-- Edge: when the countdown reaches "due" (≤0), a run should be firing; the next 4s poll refreshes `nextRunAt` to the
- newly-scheduled run. The approximation similarly refreshes when a new run appears in the runs list.
-
-### FE summary (this slice)
-- `src/features/heartbeat/logic/types.ts`: `HeartbeatNextRunResult` + `LoadHeartbeatNextRun` port.
-- `src/features/heartbeat/logic/view-model.ts`: pure `nextRunEpoch` (parse ISO), `formatCountdown`, `approximateNextRunEpoch` (+12 tests).
-- `src/app/store.svelte.ts`: `heartbeatNextRun()` (GET `.../heartbeat/next-run`; graceful 404 → `ok:false`).
-- `src/features/heartbeat/ui/HeartbeatView.svelte`: polls `nextRun` (stop-on-fail + fallback), 1s countdown clock, "Next run in …" under the status.
-- `src/app/App.svelte`: `loadHeartbeatNextRun` adapter → `HeartbeatView`.
-
-**Verification:** typecheck 0/0, 865 tests green (+12 next-run helpers), biome clean, build OK. The accurate countdown
-can only be confirmed against a running backend with CR-HB-3 shipped (enable the heartbeat, watch "Next run in …" tick
-down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE shows the APPROXIMATE countdown
-(latest run + interval) — flagged as the known gap.
-
----
-
-## 2j. Provider concurrency limits → **CONSUMED ✅ (backend shipped; FE built)**
-
-The backend tracks + limits how many concurrent token-generating API requests are in flight PER
-PROVIDER. When the cap is reached, further requests QUEUE and are granted slots oldest-agent-first
-(a 429 backoff PAUSES a provider's queue until `pausedUntil`). The cap is per-provider, managed via a
-new GLOBAL REST surface under `/concurrency/...` provided by the `concurrency` extension. **The limits
-are now PERSISTED across reboots** (backend update on `feature/provider-concurrency` — `PUT`/`DELETE`
-also write to storage; `GET /concurrency/limits` reads in-memory state pre-populated from storage on
-boot). **No API contract change** — the endpoints, request bodies, and response shapes are identical,
-so the FE needs no re-pin/re-mirror; a limit set via the UI now survives a server restart. (Earlier
-backend-only changes since the last handoff, also no FE impact: a 200ms release cooldown for internal
-slot recycling, and the `"queued"` `ConversationStatus` — which the FE already shipped, CR-13.)
-**`[email protected]`** added the 5 types: `ConcurrencyLimitsResponse`,
-`SetConcurrencyLimitRequest`, `ConcurrencyLimitResponse`,
-`ConcurrencyStatusEntry`, `ConcurrencyStatusResponse`.
-
-**Backend API (plain REST — the types ARE in `[email protected]`):**
-- `GET /concurrency/limits` → `{ limits: [{ providerId, limit }] }` (empty when extension not loaded)
-- `GET /concurrency/limits/:providerId` → `{ providerId, limit }` · 404 (not configured) · 503 (not loaded)
-- `PUT /concurrency/limits/:providerId` body `{ limit }` (positive int) → `{ providerId, limit }` · 400 (bad body) · 503
-- `DELETE /concurrency/limits/:providerId` → `{ ok, providerId }` · 404 · 503
-- `GET /concurrency/status` → `{ providers: [{ providerId, limit, inFlight, queued, paused, pausedUntil? }] }` (empty when not loaded)
-
-**Contract note:** the concurrency shapes ARE in `@dispatch/[email protected]` (a real version
-bump, unlike the additive `provider-retry`/`computer` deltas). The FE re-pinned the `file:` dep
-(`bun install`) + re-mirrored `.dispatch/transport-contract.reference.md` (appended the 5 types +
-bumped the snapshot header). The FE imports the contract types directly (no consumer-defines-port
-needed for the data shapes), exactly mirroring `mcp`/`computer`.
-
-**FE (DONE + verified):**
-- New feature library `src/features/concurrency/`:
- - `logic/types.ts` — re-exports the 5 contract types + defines the FE-owned result types
- (`ConcurrencyLimitsResult`/`ConcurrencyLimitResult`/`ConcurrencyDeleteResult`/
- `ConcurrencyStatusResult`) + the 5 injected ports (`LoadConcurrencyLimits`/
- `GetConcurrencyLimit`/`SaveConcurrencyLimit`/`DeleteConcurrencyLimit`/`LoadConcurrencyStatus`).
- - `logic/view-model.ts` (pure) — `viewConcurrencyStatus`/`viewConcurrencyStatuses` (badge +
- busy + "2/4" in-flight + queue + `paused — resumes in Ns` countdown), `viewConcurrencyLimit`/
- `Limits`, `summarizeLimits`/`summarizeStatus`, `parseLimitInput`/`normalizeLimit` (positive-int
- validation), `formatPauseDuration`/`pauseLabel`, + the network-seam normalizers
- `normalizeConcurrencyLimits`/`normalizeConcurrencyLimit`/`normalizeConcurrencyStatus` (defensive
- coercion — a malformed/empty `{}` body never crashes the renderer). 34 view-model tests green.
- - `ui/ConcurrencyView.svelte` — a sidebar panel with TWO sections: (1) **Concurrency limits**
- (config): an add form (provider id text input + positive-int limit + Add → PUT) + a list of
- `ConcurrencyLimitRow` (inline-edit limit + Save → PUT + ✕ Remove → DELETE); (2) **Live status**:
- a summary + per-provider cards (in-flight "2/4", queued, paused indicator with a live countdown),
- polling `GET /concurrency/status` every 2s + a 1s countdown clock (both intervals disposed on
- unmount). Reloads limits+status on every mutation.
- - `ui/ConcurrencyLimitRow.svelte` — one editable limit row (inline-edit seeded via the ChatLimitField
- pattern so a save echo / refresh re-syncs without clobbering an in-flight edit).
- - `index.ts` — `ConcurrencyView`/`ConcurrencyLimitRow`/`manifest`/types/exports.
-- `AppStore` (`src/app/store.svelte.ts`) — 5 methods (GLOBAL, not workspace-scoped): `concurrencyLimits()`,
- `getConcurrencyLimit(providerId)`, `setConcurrencyLimit(providerId, limit)`,
- `deleteConcurrencyLimit(providerId)`, `concurrencyStatus()`. Each normalizes the untyped JSON at the
- network seam + surfaces HTTP errors (incl. 400/404/503) as `ok:false` with the backend's `error`
- string. Interface declarations added to `AppStore`.
-- `src/app/App.svelte` — new `"concurrency"` viewKind (sidebar "Concurrency"), `concurrencyManifest`
- in `loadedModules`, thin passthrough adapters, + the `viewContent` branch rendering `<ConcurrencyView>`
- (global — no `{#key}`, stays mounted across tab switches).
-- Tests: 34 view-model + 5 component (`@testing-library/svelte`, faking the 4 ports) + 9 store
- (load/empty/503/404/PUT/400/DELETE/status coerce/empty).
-
-**Verification:** typecheck 0/0, **914 tests green** (run TWICE — no cross-test pollution; the polling
-intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome
-clean, `vite build` succeeds (the lone build warning is PRE-EXISTING — a Tailwind/DaisyUI `file:path` /
-`heartbeat:elapsed` arbitrary-CSS ambiguity, not from concurrency). Live probe NOT run (the backend
-was the user's process; never booted headless). To confirm end-to-end: start the backend with the
-`concurrency` extension loaded, open the Concurrency sidebar view, add a provider limit, watch the live
-status poll (in-flight/queued/paused), update + remove it. If the extension isn't loaded, the limits +
-status lists render empty (graceful).
-
-**Note (the single-provider GET):** the FE implements all 5 API client functions (incl.
-`getConcurrencyLimit`, endpoint #2), but the UI uses only 4 — the limits LIST covers the configured
-providers; `getConcurrencyLimit` is an API-client function for completeness/future use (a detail view).
-**No `chat.send` change** — concurrency is a transport-layer concern the backend applies to outbound
-provider calls; the agent/model prompt never sees it (does not affect prompt caching).
-
-**Worktree environment note (same as §2d):** this worktree lays the repos out as
-`…/worktrees/provider-concurrency/{backend,frontend}`, but `package.json`'s canonical `file:` paths
-point at `../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink
-`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced
-`node_modules/@dispatch/*` to pick up `[email protected]`.
-## 2j. Vision & vision handoff → **CONSUMED ✅ (backend shipped; FE built + verified)**
-
-The backend shipped image/vision support: a user can attach images to a chat message, vision-capable
-models receive them natively, and non-vision models get an auto-transcribed text description (the
-"vision handoff"). The FE now pastes/picks/drops images in the composer, renders `image` chunks in the
-transcript, and shows a vision badge in the model picker. Additive to `[email protected]` /
-`[email protected]` (**NO version bump** — `ImageChunk`/`ImageInput` were added to the existing
-versions; the FE's `file:` dep picks them up automatically, no re-pin needed).
-
-**New wire/transport types consumed + re-mirrored:**
-- `ImageChunk` (`{ type: "image", url, mimeType? }`) — a NEW `Chunk` variant. `url` is a base64 data URL
- (`data:image/…;base64,…`) OR an `http(s)://` URL. `ImageInput` (`{ url, mimeType? }`) is the transport-
- facing input shape (`ChatRequest.images`); the orchestrator converts each into an `ImageChunk` on the
- persisted user message.
-- `ChatRequest.images?: readonly ImageInput[]` (so `ChatSendMessage` — which `extends ChatRequest` —
- carries it on `chat.send`). `ChatQueueMessage` (steering) does NOT carry `images` — steering is
- text-only (correctly: a mid-turn injection has no image surface).
-- `ModelMetadata.vision?: boolean` — `true` when the model natively accepts images; absent/`false` → the
- server's vision handoff transcribes images to text before the model sees them.
-- Re-mirrored `.dispatch/wire.reference.md` (added `ImageChunk` to the `Chunk` union + the `ImageChunk`/
- `ImageInput` interfaces) and `.dispatch/transport-contract.reference.md` (added `images` to `ChatRequest`,
- `vision` to `ModelMetadata`, + `ImageChunk`/`ImageInput`/`Computer`/`ComputerEntry` to the re-export).
-
-**FE (DONE + verified):**
-- **Core (`core/chunks`):** the `assertChunkExhaustive` conformance guard caught the new `image` variant
- (its purpose) → added the `case "image"` (this was the build break). `appendUserMessage(state, text,
- images?)` now echoes a `[text, image, image, …]` user run (text first, then images in order; images-only
- when text is empty). The `user-message` event carries ONLY text (never images — images arrive via history/
- loadSince + the optimistic echo), so its de-dup was generalized: it now scans the trailing provisional
- USER run for a matching text chunk (not just the last chunk — which would be an image when images were
- pasted, causing a duplicate text bubble). `applyHistory`'s during-generation de-dup was generalized to
- match a multi-chunk user echo against the trailing committed user run by content equality
- (`chunkContentEquals` — text/thinking/image/error/system/tool-call/tool-result), dropping the whole echo
- only when fully backed (a partial match is kept until turn-seal drops all provisional wholesale). New
- pure helpers `chunkContentEquals` + `trailingRun` (both internal). +16 reducer tests.
-- **Transcript (`ChatView.svelte`):** a user `image` chunk renders as an `<img>` (lazy + async-decoded,
- max-h-80) inside the user bubble, using the chunk's `url` directly. A non-vision model's persisted user
- message keeps the original `image` chunk AND a `text` transcription (`[Image analysis (via <model>)]: …`)
- in the SAME message — both render (image, then analysis text). The `read_image` tool call/result
- renders like any other tool (its `toolName` is generic — no special-casing). +3 ChatView tests.
-- **Composer (`Composer.svelte`):** image paste (clipboard `paste` — extracts image `File` items,
- `preventDefault` only when an image is present so text paste still works), an attach-image button +
- hidden `<input type=file accept=image/* multiple>`, and drag-drop onto the form. Files are read to
- base64 data URLs (`FileReader.readAsDataURL`), capped at 8 MiB, staged as thumbnail previews with
- remove buttons. `onSend` signature widened to `(text, images?)`; an image-only send (empty text) is
- allowed; `images` is OMITTED on the wire (not `[]`) when none are staged (backward compatible). Steering
- (`onQueue`) never forwards images. +6 Composer tests.
-- **Store wiring:** `ChatStore.send(text, images?)` forwards `images` on the `chat.send` WS op + echoes
- them; `AppStore.send(text, images?)` threads images through the draft→tab promotion; `App.svelte`'s
- `handleSend(text, images?)` passes them through. The model catalog already captured `modelInfo` (now
- with `vision`); `GET /models` is unchanged. +5 store/app tests.
-- **Model picker (`ModelSelector.svelte` + `model-select.ts`):** new pure `isVisionModel(modelInfo,
- fullName)`; the model dropdown marks vision-capable models (`" · vision"`), and an indicator below shows
- "Vision — this model sees images natively" vs the handoff hint "Pasted images are auto-described". Wired
- `modelInfo={store.modelInfo}` from `App.svelte`. +8 model-select/ModelSelector tests.
-
-**Invariants held:**
-- `chat.send` STILL omits `cwd` (the persisted cwd wins) — only `images` was added to the message.
-- The `user-message` event still carries only text; images are NEVER expected on it (a watcher fetches
- them from history). The de-dup was made robust to the multi-chunk echo rather than reaching for images
- on the event.
-- `providerRetry`/`generating` unchanged; `image` is a normal committed/provisional chunk (it IS in the
- `Chunk.type` union, unlike the transient `provider-retry`), so it persists + replays on reload.
-- The `read_image` tool is rendered generically (no surface-id special-casing — the tool-name dispatch is
- already identity-free).
-
-**Verification:** `svelte-check` 0/0; vitest **901/901** (run TWICE — no cross-test pollution; +34 new:
-16 reducer, 3 ChatView, 6 Composer, 5 store/app, 4 model-select), biome clean, `vite build` succeeds (the
-one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute selectors, unrelated).
-**Live probe NOT run:** the backend was not reachable headless at verify time (it is the user's process;
-never booted headless). The full data path (paste → data URL → `chat.send` `images` → reducer echo →
-transcript render; `GET /models` `vision` → badge; history `image` chunk → render) is covered by unit +
-component + store tests. To confirm end-to-end: start the backend, paste an image into the composer with
-a vision model selected (e.g. any `kimi/*`), send, confirm the image renders + the model responds to it;
-then switch to a non-vision model (e.g. `umans/glm-5.2`), paste an image, send, confirm the image renders
-AND a numbered placeholder `[Image N attached — call consult_vision…]` text bubble appears (the non-vision
-handoff — see the update below; the old `[Image analysis (via …)]` auto-transcription is GONE); confirm
-the vision badge shows/hides per model in the picker.
-
-### 2j-update. consult_vision tool + vision settings API → **CONSUMED ✅ (backend updated; FE built + verified)**
-
-A follow-up to §2j. The backend revised the vision surface: the `read_image` tool is REPLACED by
-`consult_vision`, non-vision models get NUMBERED PLACEHOLDERS (not auto-transcriptions), and there is a
-NEW global vision-settings API. Additive to `[email protected]` / `[email protected]` (NO version bump);
-re-mirrored `.dispatch/transport-contract.reference.md` (added `VisionSettingsResponse` /
-`SetVisionSettingsRequest` + a delta note).
-
-**What changed (backend):**
-- **`read_image` → `consult_vision`** (`{ question: string (req), imageIds?: number[], path?: string }`):
- opens a NEW conversation tab with a vision-capable model (Kimi), attaches the image + question, returns
- the conversation id + the vision model's answer (suggests the dispatch CLI for follow-ups). Rendered like
- any tool call/result (generic `toolName` dispatch — no special-casing).
-- **Non-vision models get NUMBERED PLACEHOLDERS** instead of auto-transcriptions: a pasted image on a
- non-vision model persists an `image` chunk + a `text` chunk `[Image N attached — call consult_vision
- with imageIds=[N] and a specific question to analyze it]`. (The old `[Image analysis (via <model>)]: …`
- auto-transcription is GONE.)
-- **Image compaction** (transparent): when a vision model has > `imageLimit` images in history, the oldest
- are transcribed to `[Compacted image]: <description>` `text` chunks (the persisted `image` chunk stays
- for rendering). Both placeholder + compacted chunks are REGULAR `text` chunks — render as-is (no special
- handling).
-- **NEW global vision settings API:** `GET /settings/vision` → `VisionSettingsResponse`
- (`{ imageLimit: number (default 10), compactionModel: string|null (null = auto) }`); `PUT /settings/vision`
- ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable compaction;
- `compactionModel?` `<key>/<model>` or null).
-
-**FE (DONE + verified):**
-- **Tool rendering:** the ChatView `read_image` test → `consult_vision` (rendering is generic — renders by
- `toolName`, so no component change; only the test name/input updated). +2 ChatView tests for the
- placeholder text chunk + the compacted-image text chunk (both render as regular text).
-- **New `vision` feature library** (`src/features/vision/`): pure `logic/view-model.ts` (32 tests) —
- `VisionSettings`/`VisionSettingsPatch` (owned locally, consumer-defines-port; the shapes are a plain REST
- surface, mirroring heartbeat/mcp), `LoadVisionSettings`/`SaveVisionSettings` ports + result types,
- `normalizeVisionSettings` (network-seam coercion — a malformed body can't crash the renderer),
- `parseImageLimit`/`imageLimitChanged` (dirty-check), `compactionModelOptions` (filters `GET /models` to
- vision-capable models via the chat feature's public `isVisionModel` export + an "Auto" sentinel),
- `selectedCompactionValue`/`compactionModelFromValue` round-trip, `imageLimitLabel`; `ui/VisionSettingsView.svelte`
- (imageLimit text input + Save, compactionModel dropdown with Auto + vision-capable models, load-on-mount,
- save-on-change, error/saved feedback; 9 component tests); `index.ts`.
-- **Cross-unit seam:** `isVisionModel` was added to `features/chat`'s public `index.ts` (additive — the
- vision feature imports it through the public surface, not the chat internals).
-- **Store wiring** (`src/app/store.svelte.ts`): `visionSettings` reactive state + `refreshVisionSettings()`
- (`GET /settings/vision`, normalized at the seam) + `setVisionSettings(patch)` (`PUT /settings/vision`,
- returns merged settings) + `VisionSettingsResult`; seeded on boot; exposed on `AppStore`. +4 store tests.
-- **Mounted in `App.svelte`:** a new "Vision" sidebar view kind (`viewKinds`) + `VisionSettingsView` in the
- `viewContent` snippet (not conversation-scoped — no `{#key}`); `loadVisionSettings`/`saveVisionSettings`
- adapters wrap the store; `visionManifest` in `loadedModules`.
-
-**Verification:** `svelte-check` 0/0; vitest **948/948** (run TWICE — no cross-test pollution; +47 new
-since the §2j baseline: 32 vision view-model, 9 VisionSettingsView, 4 store, +2 ChatView
-placeholder/compacted, and the read_image→consult_vision test update), biome clean, `vite build` succeeds.
-**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: open the Vision sidebar
-view, confirm the imageLimit + compactionModel load; change imageLimit → Save → confirm it persists on
-reload; set compactionModel to a vision model → confirm the dropdown reflects it; paste an image with a
-non-vision model → confirm the `[Image N attached — call consult_vision…]` placeholder renders (not an
-auto-transcription); trigger a `consult_vision` tool call → confirm it renders like a tool.
-
-### 2j-update-2. Image storage (tmp, not SQLite) → **CONSUMED ✅ (backend updated; FE built + verified)**
-
-A follow-up to §2j. The backend no longer persists images as base64 data URLs in the conversation store —
-they are saved to a tmp directory and served via HTTP. The `ImageChunk.url` field's FORMAT changed (the
-TYPE is unchanged — still `string`); `GET /images/:conversationId/:imageId` is a new endpoint serving raw
-bytes + the correct Content-Type. **NO wire/transport-contract type change** (behavior only); re-mirrored
-the delta notes in `.dispatch/wire.reference.md` + `.dispatch/transport-contract.reference.md`.
-
-**What changed (backend):**
-- **BEFORE:** `ImageChunk.url` was a base64 data URL (`data:image/png;base64,…`).
-- **NOW:** `ImageChunk.url` for PERSISTED chunks (history/replay) is a compact relative HTTP path
- (`/images/<conversationId>/<uuid>.png`), served by `GET /images/:conversationId/:imageId` (raw image
- bytes + Content-Type). Images live on disk under tmp, NOT in the SQLite conversation store (keeps
- payloads small).
-- **`ChatRequest.images` (`ImageInput.url`) is UNCHANGED** — the FE still sends data URLs; the backend
- saves them to tmp and returns compact paths in the persisted chunks.
-- **Optimistic echo:** the FE's provisional echo still uses the data URL it sent (immediate render);
- when the persisted chunk arrives (via `loadSince`/`syncTail`/event stream), it carries the compact path
- and the FE switches to rendering via the HTTP endpoint.
-- The vision settings API, `consult_vision`, and image compaction are UNCHANGED (compaction resolves
- compact URLs internally).
-
-**FE (DONE + verified):**
-- **New pure helper `resolveImageUrl(url, apiBase)`** (`core/chunks/image-url.ts`, +8 tests, exported from
- `core/chunks` + re-exported from `features/chat`): a `data:` URL or an `http(s)://` URL passes through
- unchanged; a relative path (`/images/…`) is prepended with the API base (no double slash; an empty base
- yields a root-relative path a browser resolves against its origin). Pure — zero DOM/Svelte.
-- **`ChatView.svelte`:** new `apiBaseUrl` prop (default `""`); the `<img src>` now uses
- `resolveImageUrl(rendered.chunk.url, apiBaseUrl)`. The optimistic echo's data URL + any absolute URL
- pass through; persisted relative paths resolve against the base. +3 ChatView tests (relative-path
- resolution, data-URL pass-through with a base set, root-relative when no base).
-- **Store + wiring:** `httpBase` (the resolved HTTP API base) is now exposed as a getter on `AppStore`;
- `App.svelte` passes `apiBaseUrl={store.httpBase}` to `ChatView` AND to the heartbeat `RunModal` (its
- `ChatView` also renders image chunks — added an `apiBaseUrl` prop there, threaded from `App.svelte`).
-
-**Verification:** `svelte-check` 0/0; vitest **959/959** (run TWICE — no cross-test pollution; +11 since the
-prior commit: 8 `resolveImageUrl`, 3 ChatView resolution), biome clean, `vite build` succeeds.
-**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: paste an image with a
-vision model, send, confirm the image renders immediately (data-URL echo) AND continues to render after
-the turn seals (the persisted compact-path `/images/…` resolved against `httpBase`); reload the
-conversation → confirm the persisted image renders from the `/images/…` endpoint (not a data URL).
-
----
-
-## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change**
-
-The context-window usage indicator at the bottom of the screen (Composer status
-bar) now updates **after each step** during a multi-step turn, instead of only
-when the turn seals. Pure FE change — consumes wire events the backend ALREADY
-sends (`usage` per step + `step-complete` + `done.contextSize`); no contract
-change, re-pin, or re-mirror needed.
-
-- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT
- (not-done) turn, returns the most recent step WITH USAGE's
- `inputTokens + outputTokens` as the live context occupancy. Per the wire
- contract each step's input already includes all prior context (the prompt is
- re-prefilled every step), so the last step's input+output is the true occupancy
- — the same definition `TurnDoneEvent.contextSize` stamps at turn end.
-- A finalized turn (`done` / durable) still wins with its authoritative
- `contextSize`; durable still wins over live for a shared `turnId`. An in-flight
- turn with no step usage yet falls back to the next older finalized turn (never
- `0`).
-- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize`
- + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green.
-
-### FE summary (this slice)
-No backend ask. The backend already emits per-step `usage` (token counts, may
-arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus
-`done.contextSize` (final step's input+output) — the FE just wasn't reading the
-per-step usage for the live indicator. Now it does.
-## 2j-update-3. Concurrency-fixes (cooldown + adaptive headroom + usage gate) → **CONSUMED ✅ (backend shipped; FE built + verified)**
-
-A follow-up to §2j. The backend's per-provider concurrency surface gained (a) a configurable + persisted
-per-provider release **cooldown**, (b) **adaptive headroom** — a 429 auto-reduces the provider's limit by 1
-(one-way, persisted) and the FE renders a visible banner, and (c) a **usage gate** (backend polls upstream
-`concurrent_sessions` before admitting a queued agent — no FE surface). The signal rides on
-`GET /concurrency/status` (no new WS push). **Additive to `[email protected]`, NO version bump**
-(the FE's `file:` dep picks the new types up via re-sync, no re-pin needed). Backend commit
-`feature/concurrency-fixes` `2d27666` ("usage-gate + adaptive headroom + configurable cooldown").
-
-**Contract changes (re-mirrored in `.dispatch/transport-contract.reference.md`):**
-- `ConcurrencyStatusEntry` gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in
- ms, default 350; a recycled slot is held this long before the next waiter is admitted, covering the upstream
- provider's accounting lag — configurable + persisted), `autoReduced: boolean` (REQUIRED — true when the limit
- was auto-reduced by 1 after a 429, one-way + persisted; the FE renders a banner), `autoReducedFrom?: number`
- (present only when `autoReduced===true` — the original limit before reduction), and `notice?: string`
- (present only when `autoReduced===true` — a human-readable banner message).
-- NEW cooldown types: `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) +
- `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — non-negative integer, 0 = no cooldown / instant re-admission).
-- NEW endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (404 when the provider
- has no concurrency config at all — no limit AND no cooldown; 503 when the extension isn't loaded);
- `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` → `ConcurrencyCooldownResponse`
- (400 on an invalid body; 503 when not loaded). Persists + applies immediately to subsequently recycled slots.
-- A manual `PUT /concurrency/limits/:providerId` CLEARS `autoReduced` server-side (the restore path).
-
-**FE (DONE + verified):**
-- **Pure core (`logic/view-model.ts`):** `DEFAULT_COOLDOWN_MS = 350`; `parseCooldownInput` (non-negative integer —
- unlike the limit, **0 is valid**); `normalizeCooldown` (defensive default 350 on garbage);
- `cooldownLabel` ("350ms" / "1.2s" / "0ms (off)"); `viewConcurrencyStatus` extended to carry `cooldownMs` +
- `cooldownLabel` + `autoReduced` + `autoReducedFrom` (auto-reduce → `warning` badge but NOT `busy` — a reduced
- limit still admits agents); `viewAutoReduce`/`autoReduceNotices` (banner view — prefers the backend `notice`
- verbatim, synthesizes a fallback when absent, `fromLimit` = `autoReducedFrom` for "Restore to N");
- `summarizeStatus` gained an "N auto-reduced" fragment; `normalizeConcurrencyStatus` coerces the new fields
- (builds the readonly entry immutably — `autoReducedFrom`/`notice` only when `autoReduced===true`, dropped when
- false even if present in the JSON); `normalizeConcurrencyCooldown` (network-seam coercion).
-- **Types (`logic/types.ts`):** re-exports the 2 new contract types + `ConcurrencyCooldownResult` +
- `GetConcurrencyCooldown`/`SaveConcurrencyCooldown` ports.
-- **UI:** new `ui/ConcurrencyCooldownRow.svelte` (per-provider inline-edit cooldown input + Save → PUT, seeded via
- the ChatLimitField pattern so a status-poll refresh re-syncs without clobbering an in-flight edit; "Saved."
- confirmation); new `ui/AutoReduceBanner.svelte` (the dismissible banner — backend `notice` + "Was N, now M." +
- "Restore to N" PUT button + ✕ dismiss). `ConcurrencyView.svelte`: cooldown label per status card +
- `ConcurrencyCooldownRow`; an auto-reduce banner section at the top of the panel. The banner is DISMISSIBLE +
- persists while `autoReduced===true`: a dismissed provider stays hidden while still auto-reduced, and is
- un-dismissed the moment a poll shows it restored (a `$effect` reconciles the dismissed set against the live
- auto-reduced providers). "Restore to N" PUTs the limit back to `autoReducedFrom` via `saveLimit` → the next
- status poll shows `autoReduced===false` → the banner drops automatically.
-- **Store (`store.svelte.ts`):** `getConcurrencyCooldown` (`GET .../cooldown/:id`) + `setConcurrencyCooldown`
- (`PUT .../cooldown/:id` ← `{ cooldownMs }`) — both surface 400/404/503 as `ok:false` with the backend's `error`
- string + normalize the body at the seam. Interface declarations added. (Mirrors the §2j "FE implements all API
- client functions but the UI uses a subset" note: the UI seeds cooldown from the live status `cooldownMs`, so
- `getConcurrencyCooldown` is an API-client function for completeness/future use — `saveCooldown` is the wired one.)
-- **Wired in `App.svelte`:** `saveConcurrencyCooldown` adapter → `ConcurrencyView`'s `saveCooldown` prop.
-- **Tests:** +47 (view-model: `parseCooldownInput`/`cooldownLabel`/`normalizeConcurrencyCooldown`/`viewAutoReduce`/
- `autoReduceNotices`/`summarizeStatus` auto-reduced/`normalizeConcurrencyStatus` new-field coercion; component:
- cooldown label render, cooldown PUT flow, negative-input rejection, auto-reduce banner render, restore clears
- banner, dismiss-while-auto-reduced; store: `getCooldown` load/404, `setCooldown` PUT echo + 400).
-
-**Verification:** `svelte-check` 0/0; vitest **1050/1050** (run TWICE — no cross-test pollution; the polling
-intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome clean,
-`vite build` succeeds (the one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute
-selectors, unrelated). **Live probe NOT run** (the backend is the user's process; never booted headless). To
-confirm end-to-end: start the backend with the `concurrency` extension loaded, open the Concurrency sidebar view,
-confirm the cooldown label + edit field per provider; set a cooldown → Save → confirm it persists on reload;
-trigger a 429 on a limited provider → confirm the auto-reduce banner appears (with `notice` + "Was N, now M.") →
-click "Restore to N" → confirm the banner drops on the next poll.
-
-**Post-review fixes (folded into the same commit):** a Kimi review flagged a MEDIUM bug + 2 LOW issues, all fixed:
-- **MEDIUM — restore failure gave no inline feedback:** `restoreLimit` now returns a `RestoreOutcome`
- (`{ ok: true } | { ok: false; error }`) and `AutoReduceBanner.handleRestore` shows the error INLINE next to the
- restore button (cleared on retry) instead of silently re-enabling the button. New `RestoreOutcome` type in
- `logic/types.ts` + exported. 2 new tests (inline error on failure; error clears on a retry that succeeds).
-- **LOW — a11y:** the "Restore to N" text now stays visible while loading (spinner prepended, not replacing the
- text), so the button keeps its accessible name during the PUT (a spinner-only button loses its name for SR users).
-- **LOW — dismissed-banner persistence:** confirmed INTENTIONAL (not a bug). The dismissed set is component-local
- (resets on remount): `autoReduced` is a REAL persisted degraded state, so re-showing the banner on a fresh mount
- (sidebar view switch / reload) reminds the user; persisting dismissal in localStorage would risk HIDING an ongoing
- degradation (a footgun), and AGENTS.md forbids module-global ambient state. Documented in a code comment.
-
-**Worktree environment note (same as §2d/§2j):** this worktree lays the repos out as
-`…/worktrees/concurrency-fixes/{backend,frontend}`, but `package.json`'s canonical `file:` paths point at
-`../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink
-`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced
-`node_modules/@dispatch/*` to pick up the additive `[email protected]` types.
-
----
-
-## 3. Likely NEXT backend asks (heads-up, not yet requested)
-
-- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
- `modelInfo[model].contextWindow`. The Composer uses the real value (falls back to
- 1,000,000 when absent). The hardcoded `MAX_CONTEXT` is gone.
-- **Percentage-based auto-compact** → **CONSUMED ✅** — `compact-threshold` endpoint
- renamed to `compact-percent`; field is now `percent` (0-100, default 85, 0 = manual).
- CompactionView UI updated from token count to percent input (0-100).
-- **`GET /conversations`** — conversation list / sidebar (history explorer / switcher); could also
- expose a per-conversation "last model" so a reopened tab seeds its model from the server.
-- **LSP status over WS** (push) — today the FE HTTP-polls `GET /conversations/:id/lsp` on panel mount
- / cwd change + a manual refresh; a live surface/WS push would remove the manual refresh and reflect
- a server flipping to `error`/`connected` without a reload.