summaryrefslogtreecommitdiffhomepage
path: root/backend-handoff.md
diff options
context:
space:
mode:
Diffstat (limited to 'backend-handoff.md')
-rw-r--r--backend-handoff.md1209
1 files changed, 0 insertions, 1209 deletions
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.