summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--HANDOFF.md45
-rw-r--r--PLAN-mcp.md128
-rw-r--r--PLAN-per-edit-diagnostics.md44
-rw-r--r--ai-review-report.md32
-rw-r--r--backend-to-fe-handoff-2.md124
-rw-r--r--backend-to-fe-handoff.md141
-rw-r--r--broken-chat-repair-handoff.md180
-rw-r--r--crash-review-report.md86
-rw-r--r--frontend-cache-rate-handoff.md126
-rw-r--r--frontend-cache-warming-handoff.md91
-rw-r--r--frontend-cache-warming-lifecycle-handoff.md94
-rw-r--r--frontend-cancel-queued-message-handoff.md140
-rw-r--r--frontend-compaction-handoff.md167
-rw-r--r--frontend-context-size-handoff.md47
-rw-r--r--frontend-conversation-lifecycle-handoff.md102
-rw-r--r--frontend-conversation-list-handoff.md100
-rw-r--r--frontend-conversation-open-handoff.md53
-rw-r--r--frontend-cr3-user-message-handoff.md54
-rw-r--r--frontend-cwd-resolution-handoff.md95
-rw-r--r--frontend-history-windowing-handoff.md70
-rw-r--r--frontend-lsp-cwd-handoff.md133
-rw-r--r--frontend-lsp-cwd-workspace-handoff.md75
-rw-r--r--frontend-mcp-status-handoff.md117
-rw-r--r--frontend-message-queue-handoff.md189
-rw-r--r--frontend-metrics-handoff.md121
-rw-r--r--frontend-metrics-pass2-handoff.md67
-rw-r--r--frontend-model-persistence-handoff.md91
-rw-r--r--frontend-reasoning-effort-handoff.md81
-rw-r--r--frontend-stop-generation-handoff.md49
-rw-r--r--frontend-system-prompt-handoff.md73
-rw-r--r--frontend-todo-handoff.md91
-rw-r--r--frontend-turn-continuity-handoff.md83
-rw-r--r--frontend-workspace-open-handoff.md47
-rw-r--r--frontend-workspaces-handoff.md216
-rw-r--r--notes/heartbeat-setting-handoff.md130
-rw-r--r--packages/cli/src/args.test.ts43
-rw-r--r--packages/cli/src/args.ts7
-rw-r--r--packages/cli/src/main.ts2
-rw-r--r--packages/cli/src/message.test.ts39
-rw-r--r--packages/cli/src/message.ts2
-rw-r--r--packages/heartbeat/src/config-store.test.ts37
-rw-r--r--packages/heartbeat/src/config-store.ts3
-rw-r--r--packages/heartbeat/src/extension.ts13
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts127
-rw-r--r--packages/heartbeat/src/heartbeat.ts29
-rw-r--r--packages/kernel/src/contracts/runtime.ts45
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts376
-rw-r--r--packages/kernel/src/runtime/run-turn.ts27
-rw-r--r--packages/mcp/src/client.test.ts66
-rw-r--r--packages/mcp/src/client.ts48
-rw-r--r--packages/mcp/src/extension.test.ts107
-rw-r--r--packages/mcp/src/extension.ts49
-rw-r--r--packages/mcp/src/framing.test.ts157
-rw-r--r--packages/mcp/src/framing.ts176
-rw-r--r--packages/mcp/src/index.ts6
-rw-r--r--packages/mcp/src/manager.test.ts25
-rw-r--r--packages/mcp/src/manager.ts37
-rw-r--r--packages/mcp/src/rpc.test.ts5
-rw-r--r--packages/mcp/src/timeout.test.ts105
-rw-r--r--packages/mcp/src/timeout.ts114
-rw-r--r--packages/mcp/src/transport.test.ts29
-rw-r--r--packages/message-queue/src/index.ts1
-rw-r--r--packages/message-queue/src/pure.test.ts79
-rw-r--r--packages/message-queue/src/pure.ts36
-rw-r--r--packages/message-queue/src/service.test.ts69
-rw-r--r--packages/message-queue/src/service.ts33
-rw-r--r--packages/session-orchestrator/src/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts678
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts546
-rw-r--r--packages/session-orchestrator/src/queue.test.ts196
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts10
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/contract.types.test.ts21
-rw-r--r--packages/transport-contract/src/index.ts76
-rw-r--r--packages/transport-http/src/app.test.ts354
-rw-r--r--packages/transport-http/src/app.ts35
-rw-r--r--packages/transport-http/src/extension.ts1
-rw-r--r--packages/transport-http/src/logic.test.ts50
-rw-r--r--packages/transport-http/src/logic.ts22
-rw-r--r--packages/transport-ws/src/extension.ts17
-rw-r--r--packages/transport-ws/src/router.test.ts55
-rw-r--r--packages/transport-ws/src/router.ts50
-rw-r--r--tasks.md1050
84 files changed, 4014 insertions, 4525 deletions
diff --git a/.gitignore b/.gitignore
index c40e88f..9f55c28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ reports/
# Local observability journal (runtime artifact)
.dispatch/journal/
+ai-review-report.md
diff --git a/HANDOFF.md b/HANDOFF.md
deleted file mode 100644
index 29f1689..0000000
--- a/HANDOFF.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# HANDOFF — next steps for the incoming orchestrator
-
-> Read `ORCHESTRATOR.md` first (your operating manual), then `tasks.md` (live
-> status), then this file (what to do next). The project is mature; this file
-> points at the live source of truth and the current open work.
-
-## Where things stand (one paragraph)
-
-Kernel + core extensions + host-bin are built, full-fidelity (every core feature
-is a real manifest-loaded extension). The turn loop runs real tools end-to-end
-against live models. LSP integration, observability (journal/collector/trace-store),
-cache warming, turn continuity (detached turns + multi-client), skills, message
-queue + steering, metrics (live + persisted), per-conversation model/cwd/reasoning
-persistence, and broken-chat self-repair are all DONE and live-verified.
-**`tsc -b` EXIT 0 · biome clean · 1468 vitest green.** The web frontend is a
-separate repo (`../frontend`); contract changes are couriered via the user.
-
-## How to boot & smoke-test
-```bash
-cd /home/tradam/projects/dispat../backend
-# .env auto-loads DISPATCH_API_KEY + BACKEND_PORT (24203).
-# Dev stack (live-reload): bin/up (ports 24203/24205/24204)
-# Stable second stack: ../bin/up2 (ports 25203/25205/25204, isolated data)
-bun packages/host-bin/src/main.ts # boots app + collector
-curl -s -X POST localhost:24203/chat -H 'content-type: application/json' \
- -d '{"conversationId":"c1","message":"Say hello in 3 words."}'
-```
-Process cleanup uses the `[x]` bracket trick (ORCHESTRATOR §8) — leaked
-server/collector procs poison the next run's counts.
-
-## What's open now
-
-See `tasks.md` for the live checklist. As of this writing:
-- **Per-edit LSP diagnostics** (commit `8f6114b`) — committed + green, NOT yet
- live-verified against a real running server.
-- **MCP (Model Context Protocol) integration** — the next major feature. Research
- + plan in progress; see `notes/mcp-design.md` (when written) + `PLAN-mcp.md`.
-- `notes/pending-issues.md` item 1 (workspace tab) — awaiting a user handoff.
-
-## Standing reminders (from ORCHESTRATOR.md — don't relearn the hard way)
-- Summon via `opencode run` (ORCHESTRATOR §2). Parallel wave = multiple concurrent
- summons on disjoint file sets only.
-- Verify independently (typecheck/test/check) + confirm single-lane edits.
-- Keep `tasks.md` current; write decisions down before pivoting.
-- Be careful with destructive git; back up `notes/` before any reset/clean.
diff --git a/PLAN-mcp.md b/PLAN-mcp.md
deleted file mode 100644
index 560da8e..0000000
--- a/PLAN-mcp.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Plan — MCP (Model Context Protocol) Integration
-
-> **Status:** PROPOSED — awaiting user approval of design decisions (§7 of
-> `notes/mcp-design.md`).
-> Design: `notes/mcp-design.md`.
-
-## Decisions (to confirm with user)
-
-1. **One `mcp` extension** managing multiple servers (like `lsp`).
-2. **Tool name format:** `<serverId>__<toolName>` (double-underscore separator).
-3. **Phase 1: stdio transport only** (covers freecad-mcp + chrome-devtools-mcp).
-4. **Phase 1: Tools only** (no Resources/Prompts).
-5. **Phase 1: no enable/disable surface** (per-cwd config is sufficient).
-6. **Hand-rolled JSON-RPC** (adapt LSP's rpc.ts + framing.ts; no MCP SDK dep).
-
-## Implementation waves
-
-### Wave 0: Orchestrator (contracts + wiring)
-
-| What | File | Change |
-|---|---|---|
-| No kernel contract change needed | — | The existing `ToolContract` + `host.defineTool()` + `host.getTools()` + `toolsFilter` + `ToolAssembly` are sufficient. MCP tools are just `ToolContract`s registered at runtime. |
-| Glossary | `GLOSSARY.md` | Add `MCP`, `MCP server`, `MCP host` (see design §6). |
-| Root tsconfig | `tsconfig.json` | Add `@dispatch/mcp` project reference (after Wave 1). |
-| host-bin registration | `packages/host-bin/src/main.ts` | Register `mcpExt` in `CORE_EXTENSIONS` (same pattern as `lspExt`). |
-| `bun install` | `bun.lock` | Link the new workspace package. |
-
-> **No `@dispatch/transport-contract` or `@dispatch/wire` version bump** in Phase 1.
-> MCP tools are transparent to the wire (they're just tools the model calls).
-> A future surface (enable/disable, status endpoint) would bump versions.
-
-### Wave 1: `packages/mcp/` (single unit — the extension)
-
-This is the main implementation. One owner-agent builds the entire `packages/mcp/`
-directory. It depends only on `@dispatch/kernel` (contracts) and
-`@dispatch/session-orchestrator` (for the `toolsFilter` handle).
-
-| File | Responsibility |
-|---|---|
-| `src/framing.ts` | `Content-Length` framing for stdio (adapt from LSP's framing.ts — encode/decode). PURE. |
-| `src/framing.test.ts` | Unit tests for encode/decode. |
-| `src/rpc.ts` | JSON-RPC 2.0 client: `request(method, params) → result`, `notify(method, params)`, `onNotification(method, handler)`. Adapts LSP's rpc.ts. PURE (injected `writeFn`). |
-| `src/rpc.test.ts` | Unit tests for request/response/notification handling. |
-| `src/transport.ts` | Transport abstraction: `StdioTransport` (spawn child, pipe stdin/stdout through framing + rpc) + the interface for a future `HttpTransport`. Injected `spawn` (like LSP). |
-| `src/transport.test.ts` | Tests against an in-memory pipe pair (no real spawn). |
-| `src/client.ts` | MCP client: `initialize()` (send proto version + caps, receive server caps), `listTools()` → `tools/list`, `callTool(name, args, signal)` → `tools/call`, listen for `notifications/tools/list_changed`. Tracks connection state. |
-| `src/client.test.ts` | Tests with a mock JSON-RPC connection (injected transport). |
-| `src/config.ts` | PURE config resolution: `.dispatch/mcp.json` → `opencode.json` `mcp` key. Returns `ResolvedMcpServer[]` + `shadowed` flag. Mirrors LSP config.ts. |
-| `src/config.test.ts` | Config resolution tests (precedence, shadow, empty). |
-| `src/registry.ts` | Tool name namespacing (`<serverId>__<toolName>`) + `adaptTool(serverId, mcpTool, client)` → `ToolContract`. The `execute()` proxies to `client.callTool()` and flattens MCP content to a string. PURE (injected client). |
-| `src/registry.test.ts` | Tests for namespacing, content flattening, error handling. |
-| `src/manager.ts` | `McpManager`: one client per server config; lazy-spawn on first access; `status(cwd)`; `getClient(serverId)`; `shutdownAll()`. Mirrors LSP manager.ts. Injected spawn + logger. |
-| `src/manager.test.ts` | Manager lifecycle tests (lazy spawn, shutdown, broken server). |
-| `src/types.ts` | `McpServerConfig`, `McpServerStatus`, `McpService`, `McpToolInfo`, `McpContentItem`. |
-| `src/extension.ts` | manifest + `activate(host)`: real spawn adapter, config resolution per-cwd, manager, register tools via `host.defineTool` (on connect + on `list_changed`), register `toolsFilter` (drop tools from disconnected servers), `mcpServiceHandle`, `deactivate()`. |
-| `src/index.ts` | Public surface exports. |
-
-**Scoping rules for the summon:**
-- `.dispatch/package-agent.md` + `.dispatch/extension-agent.md`
-- `.dispatch/rules/`: `one-owner.md`, `isolation-over-dry.md`, `biome-clean.md`,
- `pure-core.md`, `no-internal-mocks.md`, `typed-handles.md`,
- `extension-logging.md`.
-
-**Key guidance for the agent:**
-- Read `packages/lsp/src/` (framing.ts, rpc.ts, config.ts, manager.ts,
- extension.ts) as the architectural precedent — same pattern, simpler protocol.
-- Read `packages/kernel/src/contracts/tool.ts` for `ToolContract`.
-- Read `packages/kernel/src/contracts/extension.ts` for `HostAPI`,
- `defineTool`, `addFilter`, `provideService`, `defineService`.
-- Read `packages/session-orchestrator/src/tools-filter.ts` for `ToolAssembly`
- + `toolsFilter`.
-- The MCP `initialize` flow: send `{ method: "initialize", params: {
- protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name:
- "dispatch", version: "0.0.0" } } }`, receive server capabilities, then send
- `notifications/initialized`.
-- `tools/list` returns `{ tools: [{ name, description, inputSchema }] }`.
-- `tools/call` takes `{ name, arguments }` and returns `{ content: [...],
- isError?: boolean }`.
-- Tool names must be namespaced `<serverId>__<toolName>`.
-- `concurrencySafe: false` on all MCP-adapted tools (conservative — MCP servers
- are generally stateful single-client processes).
-- `Content-Length` framing for stdio (same as LSP — the MCP spec inherited
- this from LSP).
-- No external dependencies — hand-roll the JSON-RPC + framing (adapt LSP's).
-
-### Wave 2: host-bin registration (orchestrator)
-
-After Wave 1 is verified in isolation:
-- Add `@dispatch/mcp` to root `tsconfig.json` project references.
-- `bun install` to link the workspace package.
-- Register `mcpExt` in `CORE_EXTENSIONS` in `packages/host-bin/src/main.ts`.
-- Verify: `tsc -b` EXIT 0, biome clean, full vitest pass.
-
-### Wave 3: Live verification (orchestrator)
-
-- Boot the dev stack (`bin/up`).
-- Create a `.dispatch/mcp.json` in a test cwd with a simple MCP server
- (e.g. a trivial stdio server that exposes one tool).
-- Verify: `GET /conversations/:id/lsp`-equivalent — actually, verify by
- sending a chat that triggers the model to call the MCP tool.
-- Or: test with chrome-devtools-mcp (`npx chrome-devtools-mcp`) if available.
-- Confirm: the model sees the MCP tool, calls it, gets a result.
-- Clean up test config.
-
-## Test strategy (per the asymmetric testing rule)
-
-- **Pure core** (framing, rpc, config, registry, types): zero internal mocks,
- high coverage. The RPC + framing tests use in-memory pipe pairs (injected
- transport, not mocked `@dispatch/*`). Config tests use string fixtures.
-- **Shell** (transport, manager, extension): integration tests against
- in-memory/real child processes. A few tests, not exhaustive unit coverage.
- Do NOT mock sibling extensions.
-
-## Estimated size
-
-- ~12 source files + ~11 test files.
-- Closest precedent: `packages/lsp/` (~20 files). MCP is simpler (no
- diagnostics, no incremental sync, no file watching, no sidecars).
-- Expected test count: ~60-80 new tests.
-
-## What is explicitly OUT of scope for Phase 1
-
-- Streamable HTTP transport (Phase 2).
-- MCP Resources and Prompts primitives (Phase 2).
-- Client → Server capabilities (sampling, roots, elicitation) (Phase 2+).
-- Per-conversation enable/disable surface + transport endpoints (Phase 2).
-- Tool poisoning / rug-pull hash validation (security hardening, Phase 2).
-- `mcp-scan`-style static analysis (Phase 2+).
diff --git a/PLAN-per-edit-diagnostics.md b/PLAN-per-edit-diagnostics.md
deleted file mode 100644
index 20671c2..0000000
--- a/PLAN-per-edit-diagnostics.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Plan — Live Per-Edit Diagnostics (General LSP)
-
-> **Status:** APPROVED — implementing.
-
-## Decisions (confirmed with user)
-
-1. **Multi-server aggregation** — query ALL connected servers matching the file's extension, merge diagnostics tagged by source.
-2. **Incremental sync** — capture each server's `textDocumentSync.change` during `initialize`; compute prefix/suffix diff ranges for `change: 2`; full content for `change: 1`. Generic, works for ALL LSPs.
-3. **`languageId` mapping** — extend the existing `language.ts` with `.rb`/`.rbs`, `.c`/`.cpp`, etc.
-4. **Auto-append to `edit_file`** — after a successful edit, run diagnostics on the post-edit buffer. Only append diagnostics if there are errors/warnings (severity ≤ 2). Don't append on clean edits (no noise).
-5. **60s timeout** — if diagnostics take >10s, prepend a warning: "LSP is taking unusually long. If this happens more than once, raise it to the user." Always append this if slow, regardless of whether there are errors.
-6. **General** — not Steep-specific. Works for any LSP server.
-
-## Implementation waves
-
-### Wave 1: `packages/lsp/` (single unit)
-
-| File | Change |
-|---|---|
-| `src/diff.ts` (NEW) | Pure diff: `computeChangeRange(oldText, newText)` + `offsetToPosition(text, offset)` |
-| `src/language.ts` | Add `.rb`/`.rbs` → `"ruby"`, `.c`/`.h` → `"c"`, `.cpp`/`.cc`/`.hpp` → `"cpp"` |
-| `src/diagnostics.ts` | Add `hasReceivedPush(uri)` tracking, `clearReceived(uri)`, `formatFiltered(uri, minSeverity?)` |
-| `src/client.ts` | Capture `textDocumentSync.change` from init; track open doc text; add `change(filePath, newText)` with incremental/full sync; fix `languageId` in `open()`; extend `waitForDiagnostics(filePath, opts?)` to accept `text` + `timeoutMs` + return `{ formatted, slow, timedOut }` |
-| `src/tool.ts` | `diagnostics` op: query ALL matching connected servers (not just first); merge tagged by source |
-| `src/types.ts` | Add `getDiagnostics(opts)` to `LspService` + `DiagnosticsResult` type |
-| `src/extension.ts` | Implement `getDiagnostics` (calls manager → all matching clients → merge) |
-| `src/diff.test.ts` (NEW) | Unit tests for diff functions |
-| `src/tool.test.ts` | Multi-server aggregation test |
-| `src/client.test.ts` | `change()`, `languageId`, `waitForDiagnostics` with text tests |
-
-### Wave 2: `packages/tool-edit-file/` (cross-extension)
-
-| File | Change |
-|---|---|
-| `src/extension.ts` | Import `lspServiceHandle` from `@dispatch/lsp`; `host.getService()` in activate; pass to tool |
-| `src/edit-file.ts` | After successful edit: call `getDiagnostics({ filePath, text: newContent, cwd, minSeverity: 2, timeoutMs: 60_000 })`; append if errors; append slow warning if >10s |
-| `package.json` | Add `@dispatch/lsp` dep |
-| `tsconfig.json` | Add `@dispatch/lsp` reference |
-
-### Wave 3: Build wiring (orchestrator)
-
-- Root `tsconfig.json`: add `@dispatch/tool-edit-file` → `@dispatch/lsp` ref if needed
-- `bun install` to link
-- Verify: typecheck + test + biome
diff --git a/ai-review-report.md b/ai-review-report.md
deleted file mode 100644
index 570ba05..0000000
--- a/ai-review-report.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# LSP Fixes Verification Report
-
-## Executive Summary
-The fixes implemented in `feature/lsp-fixes` successfully address the immediate crashes and the primary source of the 9.5 GB memory leak. The optional chaining fix, `fs.watch` error listener, bounded LRU document cache, and `initialize` timeout propagation are all correctly designed and do not introduce regressions. However, the memory leak fix for the diagnostics cache is **incomplete**: while actively opened documents are properly managed, diagnostics pushed for *unopened* background files will still accumulate and leak memory over time.
-
-## Per-Fix Verification
-
-### 1. Crash — TypeError from broken optional chaining (`client.ts`)
-- **Correctness:** **Correct.** The addition of the second optional chaining operator (`void this.rpc?.handleMessage(msg)?.catch(...)`) correctly evaluates to `undefined` when `this.rpc` is null, preventing the synchronous `TypeError` that previously crashed the server.
-- **Completeness:** **Complete.** Because `handleMessage` is an `async` function, it is guaranteed to return a Promise, meaning any internal errors are returned as rejections rather than synchronous throws.
-- **New Issues/Regressions:** None.
-
-### 2. Crash — Unhandled 'error' event on `fs.watch` (`extension.ts`)
-- **Correctness:** **Correct.** Attaching a no-op `'error'` listener to the `fs.watch` instance (`watcher.on("error", () => {})`) prevents Node/Bun from escalating transient filesystem errors (such as a directory vanishing during `bun install`) into unhandled exceptions that crash the main process.
-- **Completeness:** **Complete.** The watcher is properly treated as best-effort.
-- **New Issues/Regressions:** None.
-
-### 3. Memory leak — Unbounded document/diagnostic caches (`client.ts` + `diagnostics.ts`)
-- **Correctness:** **Correct (for opened files).** The `evictIfOverCap` method properly acts as an LRU cache. It uses the JavaScript `Map`'s insertion-order property by grabbing the oldest key via `this.openDocuments.keys().next().value`. The `change()` method successfully maintains LRU order by deleting and re-inserting documents so they move to the tail. Evicted files correctly send `textDocument/didClose` and clear their state via `this.diagnostics.purge()`.
-- **Completeness:** **Incomplete.** The fix bounds the memory for files the agent explicitly opens, but misses files analyzed passively. Language servers (like `pyright`, `rust-analyzer`, or `tsserver`) often scan the workspace in the background and emit `textDocument/publishDiagnostics` for files the client never touched. These trigger `DiagnosticsStore.setPushDiagnostics()`, which caches them unconditionally in the `pushDiagnostics` map. Because these background files are never placed in `openDocuments`, they are never reached by the `evictIfOverCap` loop. As a result, the `DiagnosticsStore` will still slowly leak memory over time as background files accumulate.
-- **New Issues/Regressions:** None introduced. The `closeDocument` method is carefully guarded with `wasOpen` so it doesn't send invalid `didClose` notifications for unopened files. There are no race conditions in the polling logic (`waitForDiagnostics`).
-
-### 4. Minor — Leaked promises on initialize timeout (`rpc.ts`)
-- **Correctness:** **Correct.** Passing the `timeoutMs` parameter directly into `rpc.sendRequest` (rather than wrapping it in an external `Promise.race`) successfully utilizes the connection's internal timeout handler. When the timeout triggers, `rpc.ts` explicitly deletes the request ID from the `this.pending` Map, freeing the memory.
-- **Completeness:** **Complete.** The leaked closure and promise are both cleared safely.
-- **New Issues/Regressions:** None.
-
-## Remaining Concerns
-1. **Unbounded `DiagnosticsStore` Map:** As detailed in Bug 3, `this.pushDiagnostics` and `this.pushReceived` inside `DiagnosticsStore` have no size bounds. A server pushing diagnostics for thousands of untouched files across a large monorepo will keep those strings and objects in memory forever until the client is destroyed.
-
-## Recommendations
-- **Bound passive diagnostics:** Modify `DiagnosticsStore` to implement its own LRU cache for `pushDiagnostics`, or have it coordinate with `client.ts` to ensure that even unopened files with diagnostics are eventually aged out and purged when a maximum threshold is reached.
diff --git a/backend-to-fe-handoff-2.md b/backend-to-fe-handoff-2.md
deleted file mode 100644
index 51a3e34..0000000
--- a/backend-to-fe-handoff-2.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Backend → FE handoff — context window + percentage-based compact
-
-> Courier to `../frontend`. Response to the context-window ask in
-> `backend-handoff.md` §3 + compacting rework.
-
-## What shipped
-
-1. **`GET /models` now includes `contextWindow` per model** — the FE can replace
- the hardcoded `MAX_CONTEXT = 1,000,000` with the real value.
-2. **Auto-compact is now percentage-based** (default 85% of `contextWindow`)
- instead of a flat token count (was 350k).
-
-## Bump pinned deps
-- `@dispatch/wire` → `0.11.0` (unchanged)
-- `@dispatch/transport-contract` → `0.15.0` (unchanged — the new types are
- additive to the existing version)
-
-## `GET /models` — now includes `modelInfo`
-
-The response now includes an optional `modelInfo` map alongside the existing
-`models` array. The `models` array is unchanged (backward compatible).
-
-```ts
-interface ModelsResponse {
- readonly models: readonly string[];
- readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
-}
-
-interface ModelMetadata {
- readonly contextWindow?: number; // max tokens (e.g. 200000)
-}
-```
-
-**Example response:**
-```json
-{
- "models": ["opencode/deepseek-v4-flash", "umans/umans-glm-5.2"],
- "modelInfo": {
- "opencode/deepseek-v4-flash": { "contextWindow": 128000 },
- "umans/umans-glm-5.2": { "contextWindow": 200000 }
- }
-}
-```
-
-`modelInfo` is absent when no provider reports `contextWindow`. Each key is the
-same `<credentialName>/<model>` string from the `models` array.
-
-**What the FE should do:**
-- When rendering the composer status bar, use `modelInfo[selectedModel].contextWindow`
- as the denominator for `contextSize / contextWindow · pct%`.
-- Fall back to the current hardcoded `1,000,000` when `modelInfo` is absent or
- the selected model has no `contextWindow`.
-
-## Auto-compact: now percentage-based
-
-**Old:** flat token threshold (default 350000). `contextSize >= threshold`.
-**New:** percentage of `contextWindow` (default 85%). `contextSize >= contextWindow × (percent / 100)`.
-
-Also fixed: the check now uses `contextSize` (true context occupancy = last
-step's `inputTokens + outputTokens`) instead of the overcounted aggregate
-`usage.inputTokens` (which summed every step's re-prefilled prompt).
-
-### `GET /conversations/:id/compact-percent` — read percent
-
-200: `CompactPercentResponse { conversationId, percent }`
-- `percent: 0` — auto-compact explicitly disabled (manual only).
-- `percent: null` (not stored) — **default: 85** (85% of the model's context window).
-- Any positive number (1-100) — auto-compact triggers when `contextSize`
- exceeds `percent`% of the model's `contextWindow`.
-
-### `PUT /conversations/:id/compact-percent` — set percent
-
-Body: `SetCompactPercentRequest { percent: number }`
-- `0` explicitly disables auto-compact.
-- Any positive number (1-100) sets the trigger percentage.
-- Default (when not stored) is 85.
-
-200: `CompactPercentResponse`
-
-**Renamed from `compact-threshold`** — the old endpoint paths, request types,
-and response types are gone. Update any FE code that referenced
-`compact-threshold`.
-
-## New types
-
-```ts
-// @dispatch/transport-contract
-export interface ModelMetadata {
- readonly contextWindow?: number;
-}
-
-// ModelsResponse now has modelInfo (additive — models array unchanged)
-export interface ModelsResponse {
- readonly models: readonly string[];
- readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
-}
-
-// Renamed from CompactThresholdResponse
-export interface CompactPercentResponse {
- readonly conversationId: string;
- readonly percent: number; // 0 = manual; null = default 85
-}
-
-// Renamed from SetCompactThresholdRequest
-export interface SetCompactPercentRequest {
- readonly percent: number;
-}
-```
-
-## What the FE needs to do
-
-1. **Use real `contextWindow`** from `GET /models` → `modelInfo[model].contextWindow`
- instead of hardcoded `MAX_CONTEXT = 1,000,000`.
-
-2. **Rename compact-threshold → compact-percent** in any FE code:
- - `GET /conversations/:id/compact-percent` (was `compact-threshold`)
- - `PUT /conversations/:id/compact-percent` (was `compact-threshold`)
- - `percent` field (was `threshold`)
-
-3. **Settings UI**: change the number input from "token count" to "percent
- (0-100)". Default 85. 0 = manual only.
-
-4. **No other changes** — the compact endpoint, WS message, and chain
- architecture are unchanged.
diff --git a/backend-to-fe-handoff.md b/backend-to-fe-handoff.md
deleted file mode 100644
index a6e726b..0000000
--- a/backend-to-fe-handoff.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Backend → FE handoff — CR-6 resolved + full endpoint list
-
-> Response to `backend-handoff.md` §2 CR-6. Courier back to `../frontend`.
-
-## CR-6: Assign seq during generation — RESOLVED
-
-**What changed:** The backend now persists chunks **incrementally at step
-boundaries** during generation, not only at turn-seal. The user message is
-persisted at turn start (before the first step), and each step's messages
-(assistant + tool-results) are persisted as soon as that step completes.
-
-**How it works:**
-1. Turn starts → user message is `append`ed immediately (gets seq numbers).
-2. Each step completes → step's messages are `append`ed immediately (get seq numbers).
-3. Turn seals → `turn-sealed` emitted (no batch `append` needed — already persisted).
-
-**What this means for the FE:**
-- `GET /conversations/:id?sinceSeq=N` returns committed, seq'd chunks **during
- generation**. The FE's existing `syncTail` already polls this — it will now
- find new chunks as each step completes.
-- The FE can adopt option (c) from the CR: fold events for the **current
- in-progress step** only (streaming text, thinking dots), and `syncTail` for
- **sealed steps**. The provisional state shrinks to just one step's worth of
- chunks — never a trim concern.
-- `turn-sealed` becomes a "refresh" signal — all chunks are already committed.
- The `done` event still carries final usage + contextSize (unchanged).
-
-**No wire/transport-contract change needed.** `StoredChunk` already has `seq`.
-`AgentEvent` types are unchanged. The FE just needs `syncTail` to find seq'd
-chunks during generation (which it already does).
-
-**Implementation detail:** The kernel calls a new `onStepComplete` callback
-(`RunTurnInput.onStepComplete`) after each step's messages are finalized.
-The orchestrator persists them via `conversationStore.append`. If the callback
-isn't called (e.g., test fakes), the orchestrator falls back to batch persist
-after `runTurn` returns — backward compatible.
-
----
-
-## Full endpoint list (current as of [email protected] / [email protected])
-
-### HTTP (port 24203)
-
-| Method | Path | Purpose |
-|---|---|---|
-| `POST` | `/chat` | Stream a turn (NDJSON response, `X-Conversation-Id` header) |
-| `POST` | `/chat/warm` | Cache-warm probe |
-| `GET` | `/models` | Model catalog (now includes `modelInfo` with `contextWindow` per model) |
-| `GET` | `/conversations` | List conversations (`?q=` prefix filter, `?status=active,idle` status filter) |
-| `GET` | `/conversations/:id` | Conversation history (`?sinceSeq=`, `?beforeSeq=`, `?limit=` windowing) |
-| `GET` | `/conversations/:id/metrics` | Per-turn metrics (tokens, timing) |
-| `GET` | `/conversations/:id/last` | Blocking last assistant message |
-| `GET` | `/conversations/:id/cwd` | Per-conversation working directory |
-| `PUT` | `/conversations/:id/cwd` | Set working directory |
-| `GET` | `/conversations/:id/reasoning-effort` | Per-conversation reasoning effort |
-| `PUT` | `/conversations/:id/reasoning-effort` | Set reasoning effort |
-| `GET` | `/conversations/:id/lsp` | LSP server status |
-| `GET` | `/conversations/:id/compact-percent` | Auto-compact percent (0=manual, null=default 85%) |
-| `PUT` | `/conversations/:id/compact-percent` | Set auto-compact percent |
-| `GET` | `/conversations/:id/title` | Read conversation title |
-| `PUT` | `/conversations/:id/title` | Set conversation title |
-| `POST` | `/conversations/:id/close` | Close tab (abort turn + mark `closed`) |
-| `POST` | `/conversations/:id/stop` | **NEW** — Stop generation (abort turn, keep conversation `idle`) |
-| `POST` | `/conversations/:id/compact` | **NEW** — Manual compaction (fork history + replace with summary) |
-| `POST` | `/conversations/:id/open` | **NEW** — Signal FE to open/focus tab (broadcasts `conversation.open`) |
-| `POST` | `/conversations/:id/queue` | Enqueue steering message |
-| `GET` | `/health` | Health check |
-| `GET` | `/metrics/throughput` | Per-model throughput samples |
-| `GET` | `/*` | Static frontend serving (SPA fallback, when `DISPATCH_WEB_DIR` is set) |
-
-### WebSocket (port 24205)
-
-**Client → Server:**
-| Type | Purpose |
-|---|---|
-| `chat.send` | Start a turn (stream events back via `chat.delta`) |
-| `chat.subscribe` | Watch a conversation's turns without sending |
-| `chat.unsubscribe` | Stop watching |
-| `chat.queue` | Enqueue steering (fire-and-forget) |
-| Surface ops | `surface.subscribe`, `surface.invoke`, etc. |
-
-**Server → Client (broadcasts):**
-| Type | Purpose |
-|---|---|
-| `chat.delta` | Per-conversation event (turn-start, text-delta, tool-call, usage, done, etc.) |
-| `chat.error` | Turn error |
-| `conversation.open` | **NEW** — CLI `--open` flag → open/focus a tab |
-| `conversation.statusChanged` | **NEW** — Lifecycle status change (`active`/`idle`/`closed`) |
-| `conversation.compacted` | **NEW** — History compacted (includes `newConversationId` = archive ID) |
-| Surface ops | Catalog, surface data, etc. |
-
-### New types the FE should consume
-
-```ts
-// ConversationMeta ([email protected]) — now has status + compactedFrom
-interface ConversationMeta {
- id: string;
- createdAt: number;
- lastActivityAt: number;
- title: string;
- status: "active" | "idle" | "closed";
- compactedFrom?: string; // archive ID (pre-compaction history)
-}
-
-// WS messages ([email protected])
-interface ConversationCompactedMessage {
- type: "conversation.compacted";
- conversationId: string;
- newConversationId: string; // archive ID
- messagesSummarized: number;
- messagesKept: number;
-}
-
-// HTTP response types
-interface CompactResponse {
- conversationId: string;
- newConversationId: string; // archive ID
- messagesSummarized: number;
- messagesKept: number;
-}
-
-interface CompactPercentResponse {
- conversationId: string;
- percent: number; // 0 = manual; null = default 85
-}
-
-interface SetCompactPercentRequest {
- percent: number;
-}
-```
-
-### FE handoff docs (in the backend repo)
-
-| File | Feature |
-|---|---|
-| `frontend-conversation-lifecycle-handoff.md` | Tab persistence (active/idle/closed) |
-| `frontend-compaction-handoff.md` | Compacting (non-destructive, chained archives) |
-| `frontend-stop-generation-handoff.md` | Stop generation mid-turn |
-| `frontend-conversation-list-handoff.md` | Conversation list + title + open tab |
-| `frontend-conversation-open-handoff.md` | CLI `--open` → `conversation.open` WS message |
-| `frontend-cache-rate-handoff.md` | Cache hit/miss calculation (updated for providers that don't report cache) |
diff --git a/broken-chat-repair-handoff.md b/broken-chat-repair-handoff.md
deleted file mode 100644
index 12deec0..0000000
--- a/broken-chat-repair-handoff.md
+++ /dev/null
@@ -1,180 +0,0 @@
-# Handoff → orchestrator (bcb5): broken-chat self-repair
-
-> From: diagnostic session. Agent/conversation `77574596`
-> (`77574596-3e7b-46f8-8d67-c9e17a529dee`) "broke unrecoverably." User goal:
-> **chats must self-heal when broken so they can continue.** Implement the fixes
-> below. Full diagnosis + plan also in `reports/broken-chat-repair-diagnosis.md`.
-
-## 0. Your job (TL;DR)
-
-`reconcile()` only repairs orphaned tool-calls. The production DB has **two other
-broken states** it doesn't handle, and they make a chat uncontinuable. Implement a
-read-time repair so broken chats auto-heal on next open — **no DB surgery**
-(append-only durability preserved; repair is a turn-path transform that runs on
-every `load()`). Three units, two repos:
-
-- **Wave 1 (arch-rewrite, PARALLEL — disjoint packages):**
- - `conversation-store` — extend `reconcile` (Layer 1) + harden `load()`.
- - `openai-stream` — harden `convertMessages` args (Layer 2).
-- **Wave 2 (separate repo `../claude`, SEPARATE agent):**
- - `provider-anthropic` — harden its `safeJson` (Layer 2 equivalent).
-
-**Key architectural insight that shapes the waves:** Layer 1 lives in
-`conversation-store.reconcile`, which runs in `load()` BEFORE any provider sees
-the messages. So the Layer 1 fix protects **every** provider (openai-compat AND
-anthropic) — the Claude plugin needs **no** Layer 1 change. Layer 2 (malformed
-tool-call args) is **per-provider** serialization safety, so it must be applied
-in each provider's converter (openai-stream + provider-anthropic).
-
-## 1. The break (what actually happened in `77574596`)
-
-Production DB: `/var/lib/dispatch/dispatch.db` (systemd `dispatch.service`).
-136 chunks; seq counter = 136; **all JSON valid; no orphaned tool-calls** — so
-`reconcile()` finds nothing wrong, yet the chat is uncontinuable. The tail:
-
-| seq | role | type | note |
-|---|---|---|---|
-| 133 | assistant | text | "Wave 0 fully verified…" |
-| 134 | assistant | tool-call | `todo_write`, `input` = **malformed JSON** (`json_type=text`, raw string) |
-| 135 | tool | tool-result | isError: "todo_write args must be an object with a `todos` array" |
-| 136 | assistant | **error** | `HTTP 400: unexpected character: line 1 column 1413 (char 1412). Received Model Group=glm-5.2` |
-
-### Root cause (confirmed byte-for-byte)
-- seq 134's `input` is a raw string. Parsing it fails
- `Expecting ':' delimiter: line 1 column 1413 (char 1412)` — an **exact match** to
- the provider's `unexpected character: line 1 column 1413`. The **model emitted
- malformed JSON as the `todo_write` arguments**.
-- Chain: model emits text + malformed-args tool-call (step 5) → kernel dispatches
- the tool, which returns an error result (seq 135) → kernel calls the provider
- again (step 6); the request re-includes the assistant message carrying the
- malformed `arguments` → provider 400s → persisted as an `error` chunk (seq 136).
-
-### Why it's "unrecoverable"
-- `openai-stream` `convertAssistantMessage` serializes tool-call args as
- `typeof c.input === "string" ? c.input : JSON.stringify(c.input)` — passes the
- malformed string straight through as the OpenAI `arguments` field → provider
- 400s on **every** continuation.
-- The trailing `assistant` message whose only chunk is `error` serializes to
- `content:""` + no tool_calls (error chunk is filtered out, leaving an empty
- assistant message) → also uncontinuable.
-- `reconcile()` touches neither. `load()` also has no try/catch on
- `JSON.parse(value)` — a single corrupt row would throw and brick the chat.
-
-### Scope (production DB, 140 conversations)
-- **6 conversations end in a trailing `error` chunk:** `102587c0`(seq2, HTTP 401
- model-not-supported), `2bf78252`(seq2), `61127511`(seq250), `77574596`(seq136),
- `d0d85eca`(seq2), `e1ee0989`(seq20).
-- **2 tool-calls total** carry a raw malformed-string `input`.
-- `102587c0` has **only** the trailing-error break (no args, no tool-calls) —
- proving Layer 1 is independently necessary. `77574596` has **both**.
-
-## 2. The fix
-
-### Layer 1 — `conversation-store` `reconcile.ts` (structural repair)
-Extend `reconcileWithReport` to:
-1. **Strip `error` chunks from assistant messages.** An `error` chunk is a
- failed-generation marker, never valid provider content (no provider understands
- an "error" content type) — provider-agnostic.
-2. **Drop any assistant message left with no `text` and no `tool-call` chunks**
- (the now-empty error-only message). This is what unblocks continuation. **Safe:**
- an error-only step ends with no tool-calls, so it is never followed by a `tool`
- message — no "tool-without-preceding-assistant-tool_calls" 400 can result. Keep
- the existing orphaned-tool-call synthesis unchanged.
-3. Extend `ReconcileReport` with counts of stripped error chunks / dropped messages
- (for the existing `reconcile.repair` boot/log span).
-
-Why here: the constitution designates `reconcile` as "the pure function run on load
-that repairs any partial turn." A trailing error-only assistant message IS a
-partial/broken turn. Pure, provider-agnostic, runs on every `load()` → auto-repairs
-all 6 broken chats. Repair is read-time only; storage (append-only) untouched.
-`loadSince` (FE reads) is intentionally NOT reconciled, so the user still SEES the
-error while the provider gets clean history.
-
-### Hardening — `conversation-store` `store.ts` `load()` (same unit)
-Wrap the per-chunk `JSON.parse(value)` in try/catch: on a corrupt/unparseable row,
-log + skip it (don't throw) so `reconcile` can still run on the rest. Today a single
-bad row makes `load()` throw → unrecoverable. (0 such rows today; "never leave the
-system broken" asks for it.)
-
-### Layer 2 — `openai-stream` `convert-messages.ts` (serialization safety)
-In `convertAssistantMessage`, ensure a tool-call's `arguments` is **always a valid
-JSON string**: if `input` is a string, `JSON.parse` it; on failure substitute a
-valid fallback object (e.g. `JSON.stringify({})` or a wrapped
-`{ _malformed_arguments: <truncated> }`). Objects pass through `JSON.stringify` as
-today. This neutralizes already-stored malformed args (seq 134) so the provider
-stops 400ing on continuation. Follow the SAME semantics as the Claude fix below
-(isolation over DRY: each provider reimplements locally, same behavior).
-
-### Layer 2 (equivalent) — `../claude` `provider-anthropic` `convert.ts` (SEPARATE agent)
-The Claude plugin already has a `safeJson(s)` helper (line ~115) used at
-`input: typeof c.input === "string" ? safeJson(c.input) : c.input`. But its fallback
-**returns the raw string `s` on parse failure** — for Anthropic, `tool_use.input`
-must be an object, so a raw string can still 400 when a historical malformed tool_use
-is re-sent. Fix: make `safeJson` return a **valid object fallback** (e.g. `{}`) on
-parse failure instead of the raw string. (Layer 1 does NOT apply here — the
-arch-rewrite `reconcile` already strips error chunks before the Claude provider sees
-the messages, so the Claude converter never receives error-only assistant messages.)
-
-## 3. Waves & summoning
-
-- **Wave 1 (arch-rewrite, PARALLEL):** `conversation-store` (Layer 1 + `load()`
- hardening) and `openai-stream` (Layer 2). Disjoint packages, no contract/type
- change, both depend only on already-built `@dispatch/kernel` contracts. Standard
- summon per ORCHESTRATOR §2/§3 (attach the scoped rules: conversation-store gets
- `pure-core.md`+`no-internal-mocks.md`+`typed-handles.md`+`extension-logging.md`;
- openai-stream gets `pure-core.md`+`no-internal-mocks.md`+`extension-logging.md`;
- both get `one-owner.md`+`isolation-over-dry.md`+`biome-clean.md`+`package-agent.md`+
- `extension-agent.md`).
-- **Wave 2 (separate repo, SEPARATE agent):** summon against
- `--cwd /home/tradam/projects/dispatch/claude` for `packages/provider-anthropic`
- (`convert.ts` `safeJson`). That repo has its own `AGENTS.md`; attach the
- arch-rewrite `package-agent.md`+scoped rules as needed. Can run in parallel with
- Wave 1 (different repo, no shared files).
-
-## 4. Why this auto-heals `77574596` (and the other 5) — no DB surgery
-On next open/continue, `load()` returns history ending at seq 135 (the tool-result):
-Layer 1 strips the seq-136 error message; Layer 2 sanitizes the seq-134 args to
-valid JSON. The provider receives
-`[…, assistant{text+tool-call(args:{})}, tool{error result}]` — a valid "continue
-after a tool result" state. The model sees its `todo_write` failed and adjusts.
-Chat continues. Same auto-repair applies to the other 5 (Layer 1 alone for the
-401/empty cases; Layer 1+2 for any malformed-args case).
-
-## 5. Test requirements (regression scar tissue)
-
-**conversation-store `reconcile.test.ts`:**
-- `reconcile strips error-only trailing assistant message` (the 77574596/102587c0
- shape: `[user, assistant{error}]` → `[user]`).
-- `reconcile strips error chunk but keeps sibling text`
- (`assistant{text,error}` → `assistant{text}`).
-- `reconcile drops assistant message left empty after stripping error`
- (`assistant{error}` only → dropped).
-- `reconcile keeps tool-call + strips error` (`assistant{tool-call,error}` with a
- matching result → `assistant{tool-call}`).
-- existing orphaned-tool-call behavior unchanged (regression).
-- (hardening) corrupt-JSON chunk row is skipped, rest load + reconcile.
-
-**openai-stream `convert-messages.test.ts`:**
-- `arguments is valid JSON when input is a malformed string` (seed from seq 134's
- raw string → output `JSON.parse`s, no throw).
-- `arguments passes through valid string input` and `stringifies object input`
- (regression).
-
-**provider-anthropic `convert.test.ts` (claude repo):**
-- `safeJson returns a valid object fallback on malformed string` (raw malformed
- string → `{}` or wrapped object, not the raw string).
-- `safeJson parses valid string input` (regression).
-
-## 6. Verify (ORCHESTRATOR §4)
-`bun run typecheck && bun run test && bun run check` whole-project green; both agents
-in-lane (`git status --short`); zero internal mocks in the pure-core units. Live-spot:
-open `77574596` against a probe/`bin/up` and confirm it now continues past the tool
-result instead of 400-looping.
-
-## 7. Notes / out of scope
-- **Parse-time prevention** (openai-stream / provider-anthropic could reject or
- repair malformed args when the model emits them, instead of storing a raw
- string) is a deeper follow-up; Layer 2 is the safety net that also repairs
- already-stored data.
-- Deploying the fix auto-repairs the 6 broken production chats on next load — no
- migration needed.
diff --git a/crash-review-report.md b/crash-review-report.md
deleted file mode 100644
index 272abdb..0000000
--- a/crash-review-report.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Production Crash Investigation — Independent Review
-
-## Executive Summary
-
-The production Dispatch server is experiencing two distinct failure modes under load:
-1. **Exit-code 1 Crashes**: Driven by an unhandled `EventEmitter` `'error'` event from the `ssh2` connection pool, **not** the AI-SDK as previously suspected.
-2. **Bun Runtime Segfaults**: Triggered by massive memory pressure from unbounded conversation history serialization during long multi-step agent turns, confirming the "leak" is actually a massive live working set, not a persistent memory leak.
-
-Additionally, a suspected latent crash path in the cache-warming probe has been confirmed as an Unhandled Promise Rejection.
-
----
-
-## 1. The Exit-1 Crash ("Timed out while waiting for handshake")
-
-### Finding: Confirmed Dispatch Bug in SSH Pool (Incorrect Preliminary Finding)
-The preliminary analysis hypothesized that the `error: Timed out while waiting for handshake` crash was caused by an unhandled `'error'` on an outbound TLS socket to the AI provider. **This is incorrect.**
-
-The crash actually originates from the `ssh2` package managing outbound remote computer connections, specifically within `packages/ssh/src/pool.ts`.
-
-### Technical Analysis
-- **The Evidence**: The exact string `'Timed out while waiting for handshake'` is hardcoded in `ssh2/lib/client.js` when the SSH handshake times out or keepalives fail during a re-keying phase.
-- **The Code Path**: In `packages/ssh/src/pool.ts`, the `doConnect` function attaches an `onError` listener to the `ssh2.Client` instance to catch connection failures:
- ```typescript
- client.on("error", onError);
- ```
- However, when the connection succeeds (`onReady` fires), the `cleanup()` function is called, which **removes the error listener**:
- ```typescript
- function cleanup(): void {
- clearTimeout(timer);
- client.removeListener("ready", onReady);
- client.removeListener("error", onError); // <-- Listener removed here
- }
- ```
-- **The Crash Mechanism**: After `doConnect` succeeds, the client is placed in the pool and returned to callers. If the SSH connection drops later or a timeout occurs, the `ssh2.Client` emits an `'error'` event. Because there are no longer any listeners attached for `'error'`, Node.js's `EventEmitter` escalates it to an uncaught exception, instantly crashing the process with exit code 1.
-
-### Recommendation
-**Dispatch Code Change**: Add a persistent `.on("error", ...)` handler to the `client` in `buildConnection` (or refrain from removing it) to gracefully catch post-connection drops, tear down the connection, and transition `state.value = "error"`.
-
----
-
-## 2. The Bun Native Segfaults (The 6.2 GB "Leak")
-
-### Finding: Massive Live Working Set, Not a Persistent Leak
-The preliminary investigation suspected a 2.5 GB/hour slow leak. The telemetry data confirms that the memory is **not permanently leaked**—when `activeConversations` drops to `0`, the RSS cleanly drops back down to the ~84 MB baseline. The crash is caused by unbounded live working set growth during concurrent agent turns, which fragments and overwhelms Bun's allocator.
-
-### Technical Analysis
-- **The Code Path**: `MAX_STEPS` in `packages/kernel/src/runtime/run-turn.ts` is set to `0` (unlimited). A single turn can run for hundreds of steps.
-- **The Mechanism**: In `executeStep`, every step appends new tool calls and results to the `messages` array. This array is then passed to `provider.stream()`.
-- Inside `packages/openai-stream/src/stream.ts`, the entire unbounded array is serialized into a single contiguous string every step:
- ```typescript
- const bodyString = JSON.stringify(body);
- ```
-- **The Crash**: If 4 concurrent conversations (`activeConversations = 4`) run for hundreds of steps, the `messages` arrays grow to hundreds of megabytes each. Serializing these arrays copies them into massive contiguous strings on the V8 heap on *every step*. This causes gigabytes of memory allocation churn, memory pressure spikes (peaking at 6.2 GB), and eventually triggers a native `SIGSEGV`/`SIGILL` in Bun's allocator.
-
-### Recommendation
-**Dispatch Code Change**:
-1. Reintroduce a sane `MAX_STEPS` limit (e.g., `50` or `100`) to bound the maximum length of a single turn.
-2. Implement a sliding window or context-truncation strategy for `messages` before serializing to prevent the payload from growing infinitely.
-3. **Operational Mitigation**: Apply the `MemoryMax` cgroup circuit breaker to turn the segfault into a controlled recycle while the codebase fix is developed.
-
----
-
-## 3. The Cache-Warming Latent Crash
-
-### Finding: Confirmed Latent Unhandled Promise Rejection
-The preliminary finding suspected a latent crash path due to a missing `try/catch` in the cache-warming probe. This is confirmed.
-
-### Technical Analysis
-- **The Code Path**: In `packages/session-orchestrator/src/orchestrator.ts`, the `createWarmService`'s `warm` function consumes the provider stream:
- ```typescript
- for await (const event of provider.stream(messages, assembled.tools, providerOpts)) {
- ```
-- **The Mechanism**: If the AI provider connection fails or aborts, `provider.stream` throws. Because there is no `try/catch` around this loop, the async `warm` function rejects its returned promise.
-- In `packages/cache-warming/src/warmer.ts`, the timer fires `void fireWarm(conversationId, token);`. Since the returned promise is not `await`ed or `.catch()`'d at the top level, it results in an **Unhandled Promise Rejection**.
-
-### Recommendation
-**Dispatch Code Change**: Add a `try/catch` block around the `for await` loop in `createWarmService` (or add `.catch()` to `fireWarm`) to gracefully emit an error result instead of throwing an unhandled rejection.
-
----
-
-## Assessment of Preliminary Findings
-
-- ❌ **"Unhandled TLS Socket to AI Provider"**: Incorrect. The exit-1 crash was a race condition in error listener attachment for outbound SSH connections (`ssh2`), not the AI SDK's TLS socket.
-- ✅ **"MAX_STEPS = 0 ... structural enabler for large working sets"**: Correct. Unbounded history serialization caused the massive gigabyte allocations that crashed Bun.
-- ✅ **"Cache-warming missing try/catch"**: Correct. It is an unhandled promise rejection waiting to happen.
-- ✅ **"Not an LSP leak"**: Confirmed. The memory growth is strictly tied to `activeConversations` and the unbounded turn array serialization.
diff --git a/frontend-cache-rate-handoff.md b/frontend-cache-rate-handoff.md
deleted file mode 100644
index b64a612..0000000
--- a/frontend-cache-rate-handoff.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# FE handoff — cache hit/miss + percentage (calculation guide)
-
-> **Courier doc** (backend → `../frontend`, via the user). Per ORCHESTRATOR §7
-> the backend does not write the FE repo. This describes ONLY how to compute cache
-> hit/miss + percentages from data the backend ALREADY exposes — **no UI design here**
-> (the look is specified separately) and **no backend change is required**.
-> Contracts: `@dispatch/wire` + `@dispatch/transport-contract` `0.4.0`.
-
-## TL;DR
-The cache hit rate is `cacheReadTokens / inputTokens`. Everything you need is already
-on the `usage` + `done` live events and in `GET /conversations/:id/metrics`. There is
-**no separate cache endpoint or boolean** — it's derived from token counts, exactly as
-the old `CacheRatePanel` did.
-
-## The data shape (`Usage`, from `@dispatch/wire`)
-```ts
-interface Usage {
- inputTokens: number; // TOTAL prompt tokens this step/turn, INCLUDING cached ones
- outputTokens: number;
- cacheReadTokens?: number; // input tokens served FROM cache (the "hit" count). Optional.
- cacheWriteTokens?: number; // cache-creation count. Optional; usually ABSENT (see caveats).
-}
-```
-Field semantics that matter for the math:
-- `inputTokens` is the **whole** prompt, so `cacheReadTokens ≤ inputTokens` and the rate is in `[0,1]`.
-- The cache fields are **optional** — treat `undefined` as `0` in all arithmetic.
-
-## Formulas
-```ts
-const read = u.cacheReadTokens ?? 0;
-const write = u.cacheWriteTokens ?? 0;
-
-const isHit = read > 0; // hit vs miss
-const hitRate = u.inputTokens > 0 ? read / u.inputTokens : 0; // 0..1 (guard /0)
-const hitPct = Math.round(hitRate * 100);
-const fresh = Math.max(0, u.inputTokens - read - write); // uncached input tokens
-```
-(These are byte-identical to the old `CacheRatePanel.svelte` formulas: hit rate =
-`cacheReadTokens/inputTokens` clamped; uncached = `max(0, input − read − write)`.)
-
-## Where to get `Usage` — three granularities, two channels
-
-| Scope | LIVE (WS `chat.delta` / NDJSON) | REPLAY (`GET /conversations/:id/metrics`) |
-|---|---|---|
-| **Per step** | `usage` event (`type:"usage"`, carries `stepId`, `usage`) | `TurnMetrics.steps[].usage` (each has `stepId`) |
-| **Per turn** (authoritative aggregate) | `done` event (`type:"done"`, carries `usage`, `durationMs`) | `TurnMetrics.usage` |
-| **Cumulative** (conversation) | Σ of each turn's `done.usage` | Σ of `turns[].usage` |
-
-Notes:
-- The **per-turn aggregate IS the sum of its steps** (the runtime aggregates). So when
- summing a cumulative figure, pick ONE granularity — sum `done.usage`/`TurnMetrics.usage`
- per turn, **or** sum all steps — never both (double-count).
-- `done.usage` is the authoritative per-turn total. (`turn-sealed` does NOT carry usage in
- this backend — it's just `{conversationId, turnId}`; the numbers ride the immediately
- preceding `done` event.)
-- `step-complete` is timing only (ttft/decode) — no tokens; ignore it for cache.
-
-## Live accumulation + reconcile (recommended pattern)
-1. **In-progress turn (optional live counter):** as `usage` events stream, you may sum
- `read`/`input` across the turn's steps to show a live-updating hit % for the current turn.
-2. **Turn finished:** take that turn's authoritative totals from its `done.usage`. Use it as
- the turn's final value (replace any live partial for that turn).
-3. **Cumulative (session/conversation):** add each completed turn's `done.usage` to a running
- total. Compute the cumulative hit % from the running totals (`ΣcacheRead / Σinput`).
-4. **"Last request" rate:** the most recent turn's `done.usage` (or most recent step's `usage`
- if you want per-round-trip granularity).
-
-## Replay / reopening a conversation
-On open, `GET /conversations/:id/metrics` → `ConversationMetricsResponse { turns: TurnMetrics[] }`.
-Seed the cumulative totals from `Σ turns[].usage`, the "last request" from `turns.at(-1).usage`,
-and you can render a per-turn (and per-step, via `steps[]`) breakdown — a superset of what the
-old session-cumulative-only panel could show.
-
-## Caveats (be honest in the UI)
-- **`cacheWriteTokens` is usually absent.** The current provider is OpenAI-compatible
- (OpenCode Go): it reports a cache **read** count (`cached_tokens`) but **no cache-creation**
- count. So the old panel's separate "write" row will be 0/empty. Hit/miss and the read
- percentage are unaffected. It would populate only if an Anthropic-native (or
- `cache_write`-reporting) provider is added.
-- **Optional fields:** any of the cache fields can be `undefined` (provider-dependent). Default
- to 0; never assume presence.
-- **A legitimate 0% is not a bug.** OpenAI-style providers auto-cache (no `cache_control`
- breakpoints), and short prompts below the provider's cache threshold simply won't be cached —
- `cacheReadTokens: 0` is a real "miss", not missing data. Cache reads grow as a conversation's
- resent prefix gets large enough.
-- **Provider doesn't report cache at all — distinguish from 0.** Some providers (e.g.
- **Umans**) never include `cache_read_tokens` / `cache_write_tokens` in their usage
- payload. In that case `cacheReadTokens` is `undefined` — the provider can't tell you
- whether cache was hit or missed. This is **different from `cacheReadTokens: 0`**,
- which means "cache was checked and there were 0 hits" (a real miss).
-
- The FE should distinguish these three states:
-
- | `cacheReadTokens` | Meaning | FE display |
- |---|---|---|
- | `undefined` | Provider doesn't report cache | Hide cache panel, or show "N/A" |
- | `0` | Provider reports cache; this request had 0 hits | Show "0%" (genuine miss) |
- | `> 0` | Cache hit | Show percentage |
-
- ```ts
- function cacheDisplay(u: Usage): { kind: "not-reported" } | { kind: "reported"; hitPct: number } {
- if (u.cacheReadTokens === undefined) return { kind: "not-reported" };
- const read = u.cacheReadTokens;
- const hitRate = u.inputTokens > 0 ? read / u.inputTokens : 0;
- return { kind: "reported", hitPct: Math.round(hitRate * 100) };
- }
- ```
-
- When `kind === "not-reported"`, do NOT show "0%" — that's misleading. Either hide the
- cache panel entirely or show "Cache: not reported". This also applies to `cacheWriteTokens`
- (if `undefined`, don't show a write row).
-
-## Worked example (real numbers, captured live against OpenCode Go flash)
-| Turn | inputTokens | cacheReadTokens | hit % |
-|---|---|---|---|
-| 1 | 2669 | 384 | 14% |
-| 2 (history resent) | 2737 | 2560 | **93%** |
-
-Cumulative: read `2944` / input `5406` → **54%**. These exact values appear both on the live
-`done.usage` stream and in `GET /conversations/:id/metrics` (`turns[].usage`).
-
-## Type references
-- `@dispatch/wire`: `Usage`, `TurnUsageEvent` (`usage`), `TurnDoneEvent` (`done`),
- `TurnMetrics`, `StepMetrics`.
-- `@dispatch/transport-contract`: `ConversationMetricsResponse`, and the WS `chat.delta`
- envelope carrying each `AgentEvent`.
diff --git a/frontend-cache-warming-handoff.md b/frontend-cache-warming-handoff.md
deleted file mode 100644
index cf1f402..0000000
--- a/frontend-cache-warming-handoff.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# FE handoff — cache warming: cache-rate fix + "expected cache" metric
-
-> **Courier doc** (backend → `../frontend`, via the user). Per ORCHESTRATOR §7 the backend does
-> NOT write the FE repo. `lsp references` does not span the two repos.
-> Backend commits: `7ffb6b2` (arch-rewrite), `0e9d118` (`../claude/provider-anthropic`).
-
-## Status — most of the original handoff is DONE (removed)
-Per the FE's `backend-handoff.md` (2026-06-11), the frontend has already consumed the bulk of the
-earlier version of this doc — those sections are **removed**:
-- ✅ `NumberField` (`kind:"number"`) renderer.
-- ✅ Conversation-scoped surface subscriptions (focused `conversationId` on subscribe/invoke +
- staleness rule; re-scope on conversation switch).
-- ✅ The "Cache Warming" sidebar view: enabled toggle, minutes+seconds interval (`cache-warming/
- set-interval`), `cache-warming/toggle`, manual **Warm now** (`POST /chat/warm`), live countdown,
- hit-% history.
-- ✅ `warmNow()` posting `/chat/warm` with the conversation's model.
-
-What remains below is the ONE piece the FE has not yet consumed: a cache-rate **correctness fix** and
-a new **retention** metric.
-
-## Cache-rate metric — a correctness fix + the "expected cache" metric (TO CONSUME)
-A backend bug made the cache-hit % read **100% on Claude whenever anything was cached** (it inflated).
-Root cause: Anthropic's `input_tokens` is the *uncached remainder*, with cache read/creation reported
-separately — but the wire `Usage.inputTokens` convention (which the flash/OpenAI-compat provider
-already follows) is the **TOTAL prompt incl. cached**. Fixed in `../claude/provider-anthropic`
-(`inputTokens = input + cacheRead + cacheWrite`). **No FE change needed for the fix itself** — your
-existing `cacheRead/inputTokens` math (in `frontend-cache-rate-handoff.md`) now yields the *true* rate
-on Claude. (That older handoff's caveat "cacheWriteTokens is usually absent" is **not** true for
-Claude — it reports both.)
-
-Show two distinct cache numbers:
-- **Cache rate** = `cacheReadTokens / inputTokens` — *what fraction of THIS turn's prompt came from
- cache*. Legitimately **drops when a turn adds a lot of new content** (e.g. pasting a big file: reads
- the old prefix back but also writes the new file → rate < 100%). Per-turn efficiency; on every
- `usage`/`done` event + persisted metrics.
-- **Expected cache (retention)** = *of the cache that existed going into this turn, how much we read
- back* — ideally **~100% every turn after the first**. **<100% = the cache busted/expired.** It is a
- **cross-turn** derivation (FE-side, from two consecutive turns' usage you already have):
- ```
- expectedCache(turn N) = clamp01( cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}) )
- ```
- (denominator = the prior turn's cached prefix = what it read + what it wrote).
-
-**Worked example (live, Claude haiku), one chat, two real turns:**
-| turn | inputTokens (total) | cacheRead | cacheWrite | cache rate `cr/input` | expected cache (cross-turn) |
-|---|---|---|---|---|---|
-| 1 (fresh) | 5149 | 0 | 5146 | 0% | — |
-| 2 (new msg) | 8462 | 5146 | 3313 | **61%** | `5146/(0+5146)` = **100%** |
-
-So on turn 2 the prompt was 61% cache (the rest was the new message), yet you read back **100%** of
-what turn 1 cached — two true, complementary signals. (Pre-fix, the rate wrongly showed 100% because
-the denominator excluded the 5146 cached tokens.)
-
-### Warming-specific (already on the wire — small additions)
-For the warming feature, the backend now also reports a **single-shot** retention so you don't have to
-track cross-turn state there:
-- **`WarmResponse.expectedCacheRate`** (new field on `POST /chat/warm`) =
- `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` — ~**100%** when the warm
- found the cache still warm, **0%** when it had expired (rewrote everything). This is the **"is
- warming working?"** signal — headline this for the Warm-now result rather than `cachePct`.
-- The conversation-scoped `cache-warming` surface gained a matching **`stat` "cache retention"** field
- (alongside the existing "last cache rate" stat). It's a generic `stat`, so your existing renderer
- already shows it — just relabel/position as desired.
-
-Types: `@dispatch/transport-contract` `WarmResponse` now carries `expectedCacheRate` (additive).
-
-## CR-3 — DONE (next-warm timestamps + manual-warm resets the timer)
-Both asks from `backend-handoff-cache-warming-timer.md` are implemented (commit `bfbad3a`). No
-contract bump (uses the `custom` escape hatch, as you suggested).
-
-**Ask 1 — authoritative timestamps on the `cache-warming` surface.** The conversation-scoped spec now
-includes a `custom` field:
-```ts
-{ kind: "custom", rendererId: "cache-warming-timer",
- payload: { nextWarmAt: number | null, lastWarmAt: number | null } } // epoch-ms
-```
-- `nextWarmAt` = epoch-ms the next AUTOMATIC warm will fire, or `null` when not scheduled (disabled,
- or a turn is generating so the timer is cancelled). Drive your countdown off this directly.
-- `lastWarmAt` = epoch-ms of the most recent completed warm, or `null` if none. Use its changes for
- the history. (The hit-% for that warm is the `last cache rate` / `cache retention` stats in the
- same spec.)
-- Pushed via the normal surface `update` on every change (warm complete, toggle, interval, turn
- start/settle). You can drop the FE-side best-effort countdown anchor.
-
-**Ask 2 — a manual `POST /chat/warm` now resets the cycle + refreshes the surface.** Implemented via
-an inversion (no new endpoint, no change to the `/chat/warm` request/response): the backend's warm
-service emits an internal event that the cache-warming extension consumes, so a manual warm now
-re-arms the automatic timer (new `nextWarmAt`), updates `lastPct`/`lastWarmAt`, and **pushes a surface
-`update`**. So after a "Warm now" click you'll get an authoritative surface `update` — you can drop the
-workaround of reading the % from the HTTP response (though the HTTP `WarmResponse` is still returned and
-fine to use for immediate feedback). Live-verified against Claude haiku.
diff --git a/frontend-cache-warming-lifecycle-handoff.md b/frontend-cache-warming-lifecycle-handoff.md
deleted file mode 100644
index 49bee0a..0000000
--- a/frontend-cache-warming-lifecycle-handoff.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# FE handoff — CR-4 cache-warming lifecycle SHIPPED (+ CR-1 table, CR-2 scope)
-
-> **Courier doc** (backend → `../frontend`, via the user). Response to your
-> `backend-handoff-cache-warming.md` (CR-4) and the open asks CR-1 / CR-2 in
-> `backend-handoff.md`. Everything below is live on `bin/up` and verified with a
-> headless probe (same flow as your `scripts/probe-cache-warming.ts` — re-run it to
-> confirm; default-off means Phase C's toggle-enable branch now executes).
->
-> **Contract bumps to re-pin:** `@dispatch/ui-contract` **0.1.0 → 0.2.0**,
-> `@dispatch/transport-contract` **0.8.0 → 0.9.0**. `wire` unchanged (0.6.0).
-
-## CR-4a — warming now defaults OFF ✅
-A new conversation starts `enabled: false`, `nextWarmAt: null` — no warm is scheduled
-until the user opts in via the toggle. Interval default is still 240s. Bonus fix:
-re-enabling restores the conversation's PERSISTED interval (not the 240s default).
-One caveat (pre-existing behavior, now fail-safe): opt-in is not yet re-hydrated
-across a backend RESTART — after a restart a conversation reads disabled until
-toggled again. Flag it if that matters to you and we'll add boot hydration.
-
-## CR-4b — post-warm updates now carry the FUTURE `nextWarmAt` ✅
-Root cause was notify-before-reschedule in the warmer. Fixed; additionally:
-- after every automatic warm, the pushed `cache-warming-timer` payload is
- `{ nextWarmAt: <future>, lastWarmAt: <just now> }` (probe: 2 warms @5s, both FUTURE);
-- after `turn-sealed` the surface now pushes the fresh post-turn schedule (this was
- the "still past after a real chat turn" case in your probe);
-- on `turn-start` the surface pushes `nextWarmAt: null` (nothing scheduled while
- generating — render as your "waiting…" state);
-- if a warm completes with warming since-disabled, the update carries
- `nextWarmAt: null`, never a stale past timestamp.
-Your countdown can stay authoritative off `nextWarmAt`; the cosmetic past-value guard
-should now be dead code.
-
-## CR-4c — `POST /conversations/:id/close` ✅ (the tab-close affordance)
-New endpoint (no request body), `[email protected]`:
-
-```ts
-interface CloseConversationResponse {
- conversationId: string;
- abortedTurn: boolean; // true iff an in-flight turn existed and was aborted
-}
-```
-
-Semantics — exactly the asymmetry the user wanted:
-- **Aborts any in-flight turn.** The kernel stops at the next event boundary; the
- partial turn is PERSISTED and the turn SEALS normally — watchers receive
- `done` (with `reason: "aborted"`) then `turn-sealed`, so your stream-derived
- `generating` flag clears with no special-casing. Live-verified.
-- **Stops + disables cache-warming** for the conversation (persisted OFF — reopening
- the conversation later does not resume warming), and pushes a surface update
- (`enabled: false`, `nextWarmAt: null`) to subscribers.
-- **Idempotent**: closing an idle/unknown conversation is a 200 with
- `abortedTurn: false`.
-- Browser/socket disconnect and `chat.unsubscribe` are UNCHANGED — they still never
- touch the turn or the warming schedule (your "keep running when the window closes"
- half is regression-tested).
-Wire this into `store.closeTab()`; `fetch`/`sendBeacon` both fine (CORS already
-allows POST).
-
-## CR-4d — initial `surface` echo ✅ (no backend change was needed)
-HEAD already echoes `conversationId` on the initial `surface` reply (shipped in the
-per-conversation-scoping commit; unit-tested). We live-probed BOTH stacks today —
-:24205 and your :25205 — and the echo is present. Your probe most likely ran against
-a `bin/up2` instance booted before that commit (up2 freezes code at boot). Re-run
-`bin/up2` and your probe; if you still see a missing echo, send us the raw frame.
-
-## CR-1 — Loaded Extensions table ✅
-The surface now emits the "Loaded" count stat plus ONE custom field:
-
-```ts
-{ kind: "custom", rendererId: "table", payload: { columns, rows } }
-// columns: ["Name", "Version", "Trust", "Activation"]
-// rows: one per loaded extension (ALL trust tiers), cell-for-cell aligned
-```
-
-Typed payload is exported as `TablePayload` (+ `TABLE_RENDERER_ID`) from
-`@dispatch/surface-loaded-extensions` if you want to narrow instead of duck-typing.
-Note: `Version` cells all read `0.0.0` — manifests are genuinely unversioned today
-(the optional data-quality item from your handoff; not done).
-
-## CR-2 — catalog `scope` flag ✅ (`[email protected]`)
-`SurfaceCatalogEntry` gains `scope?: "global" | "conversation"`. Emitted today:
-`loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Treat ABSENT as
-conversation-scoped (conservative — your current always-send-conversationId policy
-remains correct for both). You can now skip re-subscribing `scope: "global"` surfaces
-on conversation switch.
-
-## Suggested FE follow-ups (from your own queue)
-- Re-pin + re-mirror `.dispatch/{ui-contract,transport-contract}.reference.md`.
-- Wire `POST /conversations/:id/close` into the tab-close path.
-- Extend `probe-cache-warming.ts`: assert default-off, post-warm FUTURE `nextWarmAt`,
- and (new) close → `abortedTurn` + `done.reason === "aborted"`.
-- The "waiting…" guard for a past `nextWarmAt` can stay as a belt-and-braces guard
- but should never trigger now; `nextWarmAt: null` while generating is the real state
- to render.
diff --git a/frontend-cancel-queued-message-handoff.md b/frontend-cancel-queued-message-handoff.md
new file mode 100644
index 0000000..b92abd4
--- /dev/null
+++ b/frontend-cancel-queued-message-handoff.md
@@ -0,0 +1,140 @@
+# FE handoff — cancel a queued message
+
+Courier this to `../frontend` (cross-repo contract change; `lsp references` does
+not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing
+breaks.
+
+## What shipped (backend)
+
+A per-message **cancel** for the steering message queue: while a turn is
+GENERATING and a user message is sitting in the queue (waiting to be delivered
+as steering at the next tool-result boundary, or carried into a new turn), the
+client can **cancel a single queued message by id** so it never runs. The
+message is removed from the queue and is never delivered as steering and never
+carried into a new turn.
+
+This complements the existing `chat.queue` enqueue (see
+`frontend-message-queue-handoff.md`). Enqueue adds; cancel removes one.
+
+Versions: `@dispatch/transport-contract` `0.23.0 → 0.24.0`. Bump the pinned
+`file:` dep. (`@dispatch/wire` is unchanged — `QueuedMessage` already has the
+`id` the cancel targets; no new wire type was needed.)
+
+## The two entry points (pick one — same backend behavior)
+
+### 1. WebSocket op: `chat.queue.cancel` (what the FE should use)
+
+```ts
+interface ChatQueueCancelMessage {
+ readonly type: "chat.queue.cancel";
+ readonly conversationId: string;
+ readonly messageId: string; // the stable QueuedMessage.id (from the queue surface / enqueue response)
+}
+```
+(additive to `WsClientMessage`.)
+
+- **Fire-and-forget**, exactly like `chat.queue`. On success the server emits
+ NOTHING back — the `message-queue` SURFACE updates (the cancelled message
+ leaves the `payload.messages` snapshot). On failure (missing/empty
+ `conversationId` or `messageId`) the server replies `chat.error`
+ (`{ type: "chat.error"; conversationId?; message }`).
+- **Idempotent:** cancelling a message that is no longer queued (already
+ drained/delivered as steering, or already cancelled, or never existed, or an
+ unknown conversation) is a **silent no-op** — no surface update, no error. So
+ a client may optimistically remove the row from its queue UI on click and
+ fire-and-forget the cancel; if the message was already gone, nothing breaks.
+- **`messageId`** is the stable client-visible `QueuedMessage.id` you already
+ render from the queue surface snapshot (or got back from `chat.queue` /
+ `POST /conversations/:id/queue`'s `queue[]`).
+
+### 2. HTTP path (for the CLI / non-WS clients; the FE uses the WS op above)
+
+`DELETE /conversations/:id/queue/:messageId` (no request body) → `QueueCancelResponse`:
+
+```ts
+interface QueueCancelResponse {
+ readonly conversationId: string;
+ readonly cancelled: boolean; // true = a message was found + removed
+ readonly queue: readonly QueuedMessage[]; // post-cancel snapshot
+}
+```
+- `cancelled: true` — the message was in the queue and has been removed (it will
+ never run).
+- `cancelled: false` — the message was NOT in the queue (already
+ drained/delivered, never existed, unknown conversation) OR the message-queue
+ extension isn't loaded (degraded). Still HTTP **200** (idempotent — not an
+ error).
+- `queue` is the post-cancel snapshot (empty when no queue extension is loaded).
+
+## How the FE confirms a cancel
+
+The queue is control/state on the **surface** channel (NOT the chat stream), so
+cancel is confirmed the same way enqueue is — by the `message-queue` surface
+updating:
+
+1. You already **subscribe to the `message-queue` surface** (scope
+ `conversation`) and render `payload.messages` (`QueuedMessage[]`) with the
+ `rendererId: "message-queue"` custom renderer (per
+ `frontend-message-queue-handoff.md`).
+2. On cancel, the surface pushes a **full new spec** whose `payload.messages`
+ no longer contains the cancelled id. The cancelled message simply leaves the
+ queue list — render the new snapshot.
+3. **No new `AgentEvent`** is emitted for a cancel. The cancelled message never
+ appears in the transcript (it was never delivered as steering — that's the
+ point). If a message was already drained (delivered as a `steering` bubble or
+ carried into a new turn's `user-message`) before the cancel arrived, the
+ cancel is a no-op (`cancelled: false`) and the transcript is unchanged.
+
+## UX suggestion
+
+- Render a **cancel (×) affordance** on each pending row in the queue UI (the
+ surface snapshot gives you the `id` to target). On click → send
+ `chat.queue.cancel { conversationId, messageId }`.
+- Optimistically remove the row from the queue UI on click; the surface update
+ will confirm it (or, if the message was already drained, the surface already
+ shows it gone — no harm).
+- Expect a `chat.error` only for a malformed send (empty `conversationId` /
+ `messageId`) — in practice a client that sends the id it just rendered will
+ never hit this.
+
+## Race notes (safe by construction)
+
+- **Cancel vs. drain (steering delivery):** if the kernel drains the queue at a
+ tool-result boundary in the instant between the user clicking cancel and the
+ server processing it, the message is already gone — the cancel returns
+ `cancelled: false` (a no-op). The drained message was delivered as a
+ `steering` event and is in the transcript; the cancel correctly did nothing.
+ No double-delivery, no error.
+- **Cancel vs. post-seal carry:** same — if the turn sealed and the queue was
+ carried into a new turn before the cancel ran, the message is already the new
+ turn's opening `user-message`; the cancel is a no-op.
+- **Cancel is scoped per conversation:** cancelling on conversation A never
+ touches conversation B's queue.
+
+## What we need the FE to do
+
+1. **Bump pinned dep:** `@dispatch/transport-contract` → `0.24.0`.
+2. **Add `chat.queue.cancel`** to the FE's `WsClientMessage` union (it is
+ additive — no exhaustive switch breaks; if the FE has one, add the
+ `chat.queue.cancel` case to its WS dispatcher).
+3. **Cancel affordance per queued row:** a × / cancel button on each pending
+ message in the queue UI that sends
+ `chat.queue.cancel { conversationId, messageId }` using the row's `id`.
+ Optimistically remove the row; reconcile from the surface update.
+4. **No new event handling** — the existing `message-queue` surface subscription
+ already reflects the post-cancel snapshot; just render it. No `steering` /
+ transcript change for a cancelled message (it never runs).
+
+## Notes / known gaps
+
+- **No CLI command.** The CLI's `send --queue` enqueues but there is no CLI
+ command to list the queue or obtain a `messageId`, so a CLI `cancel` was not
+ added (it would have no way to discover a message id). The HTTP
+ `DELETE /conversations/:id/queue/:messageId` is available for any non-WS
+ client that already knows the id (e.g. from a prior
+ `POST /conversations/:id/queue` response's `queue[]`).
+- **Close-with-queued-messages** (the open product question noted in
+ `frontend-message-queue-handoff.md`) is unchanged by this feature: an
+ explicit `POST /conversations/:id/close` still aborts the in-flight turn and
+ the carry still fires. Cancel is a separate, per-message affordance that does
+ not touch the turn.
diff --git a/frontend-compaction-handoff.md b/frontend-compaction-handoff.md
deleted file mode 100644
index 195bc1e..0000000
--- a/frontend-compaction-handoff.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# FE handoff — conversation compacting
-
-Courier this to `../frontend`. All changes are ADDITIVE.
-
-## What shipped (backend)
-
-Conversation compaction: summarize old history into a summary + recent N,
-preserving the full pre-compaction history in a separate archive conversation.
-Creates a linked chain of archives you can walk backward.
-
-Two modes:
-- **Manual**: `POST /conversations/:id/compact` — triggers immediately.
-- **Automatic**: after each turn settles, the backend checks if the last turn's
- input tokens exceeded the per-conversation `compactThreshold` (default 85).
- If so, compaction runs automatically (fire-and-forget, non-blocking).
-
-## How compaction works — non-destructive, chained
-
-The compacted conversation **keeps its original ID** (so messaging between
-agents still works). The old full history is **forked** to a new archive
-conversation (new UUID). The archive inherits the source's `compactedFrom`,
-creating a chain:
-
-```
-Compaction 1: A (ID "abc") — full history forked to X (new ID).
- A's history replaced with [summary + recent N].
- A.compactedFrom = X
-
-Compaction 2: A (ID "abc") — current history forked to Y (new ID).
- A's history replaced with [new summary + recent N].
- A.compactedFrom = Y
- Y.compactedFrom = X (inherited from A's pre-compaction state)
-
-Chain: A → Y → X (walk compactedFrom backward)
-```
-
-Each archive is an **immutable snapshot** — a complete copy of the conversation
-at the time of that compaction. History is never destroyed.
-
-The FE **does not switch tabs** — the conversation ID doesn't change. Just
-reload the history.
-
-## Bump pinned deps
-- `@dispatch/wire` → `0.11.0`
-- `@dispatch/transport-contract` → `0.15.0`
-
-## New types
-
-```ts
-// @dispatch/wire — ConversationMeta now has compactedFrom
-export interface ConversationMeta {
- readonly id: string;
- readonly createdAt: number;
- readonly lastActivityAt: number;
- readonly title: string;
- readonly status: ConversationStatus; // "active" | "idle" | "closed"
- /** Points to the archive conversation with full pre-compaction history. */
- readonly compactedFrom?: string;
-}
-
-// @dispatch/wire
-export interface CompactionResult {
- readonly summary: string;
- readonly newConversationId: string; // ID of the archive (old full history)
- readonly messagesSummarized: number;
- readonly messagesKept: number;
-}
-
-// @dispatch/transport-contract — WS message (server → client)
-export interface ConversationCompactedMessage {
- readonly type: "conversation.compacted";
- readonly conversationId: string; // the conversation (ID unchanged)
- readonly newConversationId: string; // the archive ID (old full history)
- readonly messagesSummarized: number;
- readonly messagesKept: number;
-}
-// Added to WsServerMessage union.
-
-// @dispatch/transport-contract — HTTP response types
-export interface CompactResponse {
- readonly conversationId: string; // the conversation (ID unchanged)
- readonly newConversationId: string; // the archive ID (old full history)
- readonly messagesSummarized: number;
- readonly messagesKept: number;
-}
-
-export interface CompactPercentResponse {
- readonly conversationId: string;
- readonly percent: number; // 0 = manual only; null = default 85
-}
-
-export interface SetCompactPercentRequest {
- readonly percent: number;
-}
-```
-
-## `POST /conversations/:id/compact` — manual compaction
-
-Triggers compaction on demand. Optional JSON body:
-```json
-{ "keepLastN": 10, "modelName": "umans/umans-glm-5.2" }
-```
-- `keepLastN` (default 10): how many recent messages to retain.
-- `modelName`: override the model used for summarization.
-
-200 response: `CompactResponse` — includes `newConversationId` (the archive ID).
-The conversation ID in the response is the same as the request — the ID doesn't
-change. The FE should reload the conversation history.
-
-409: `{ error: string }` — conversation is generating, too short, percent not exceeded, etc.
-503: compaction service not available.
-
-## `GET /conversations/:id/compact-percent` — read percent
-
-200: `CompactPercentResponse { conversationId, percent }`
-- `percent: 0` — auto-compact explicitly disabled (manual only).
-- `percent: null` (not stored) — **default: 85** (85% tokens). The FE
- should display 85 as the default value in the settings UI.
-- Any positive number — auto-compact triggers when the last turn's input tokens
- exceed this value.
-
-## `PUT /conversations/:id/compact-percent` — set percent
-
-Body: `SetCompactPercentRequest { percent: number }`
-- `0` explicitly disables auto-compact.
-- Any positive number sets the trigger percent.
-- To "reset to default", set it to 85.
-
-## `conversation.compacted` WS message
-
-Broadcast to all connected WS clients when compaction completes. The FE should
-**reload the conversation history** via `GET /conversations/:id` (the
-conversation ID hasn't changed — just reload the same ID). The first message
-will now be a system summary.
-
-No tab switching needed — the ID is the same.
-
-## What the FE needs to do
-
-1. **Compact button** in the conversation toolbar → `POST /conversations/:id/compact`.
- Show a loading indicator while waiting. On success, reload the conversation
- history (same ID — just re-fetch).
-
-2. **Settings UI** for compact percent: `PUT /conversations/:id/compact-percent`
- with `{ percent: number }`. A number input (0 = manual only, default 85).
- Read the current value via `GET /conversations/:id/compact-percent`.
-
-3. **Handle `conversation.compacted` WS messages**: reload the conversation
- history via `GET /conversations/:id` (same ID, no tab switch).
-
-4. **"View predecessor" link**: when `ConversationMeta.compactedFrom` is present,
- show a link that opens the archive conversation in a read-only view (or a new
- tab). Load it via `GET /conversations/:compactedFrom`. The archive has
- `status: "closed"` and title `"Archive: <original>"`. Each archive may also
- have its own `compactedFrom` — walk the chain backward to see every snapshot.
-
-5. **Archives in conversation list**: archives appear in
- `GET /conversations?status=closed`. They have `compactedFrom` chaining to
- the previous archive (if any). The FE can show them in a history view.
-
-6. **Visual indicator**: show a badge on conversations that have a
- `compactedFrom` (they've been compacted). E.g. "Compacted" badge or chain icon.
-
-## CLI
-
-`dispatch compact <conversationId>` — triggers manual compaction. Resolves
-short IDs like other commands. The response includes the archive ID.
diff --git a/frontend-context-size-handoff.md b/frontend-context-size-handoff.md
deleted file mode 100644
index a774a0c..0000000
--- a/frontend-context-size-handoff.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# FE handoff — context size (current context-window usage)
-
-Courier this to `../frontend` (cross-repo contract change; `lsp references` does not
-span repos — ORCHESTRATOR §7). Backend commit adds an optional `contextSize` field; no
-breaking change.
-
-## What shipped (backend)
-
-A new optional field **`contextSize`** (a token count) now flows to the frontend on two
-existing carriers. Both are computed identically and are EQUAL for the same turn:
-
-1. **Live** — `TurnDoneEvent.contextSize?: number` (the `done` AgentEvent, arriving in a
- `chat.delta` WS message / the NDJSON stream).
-2. **Persisted** — `TurnMetrics.contextSize?: number`, served by
- `GET /conversations/:id/metrics` (`ConversationMetricsResponse.turns[].contextSize`).
-
-Types: `@dispatch/wire` (`0.4.0 → 0.5.0`), re-exported by
-`@dispatch/transport-contract` (`0.5.0 → 0.6.0`). Bump the pinned `file:` deps.
-
-## Definition (read this — it's subtle)
-
-`contextSize` = **the turn's FINAL step `inputTokens + outputTokens`** — the tokens the
-conversation occupies right now.
-
-It is deliberately **NOT** the aggregate `usage` already on `done` / `TurnMetrics`.
-`usage.inputTokens` is the SUM across steps, which **overcounts** a multi-step / tool-calling
-turn (each step re-prefills the growing prompt). The final step's input already contains all
-prior context, so `finalStep.input + finalStep.output` is the true occupancy. Do not derive
-context size from `usage` yourself — read `contextSize`.
-
-## How to render it
-
-- **Current value = the LATEST turn's `contextSize`.** The chat's "current context usage" is
- whatever the most recent turn reported.
-- **Live update:** when a `done` event arrives, if `event.contextSize !== undefined`, set the
- displayed context size to it.
-- **On (re)hydrate:** call `GET /conversations/:id/metrics`, take the LAST element of `turns`
- that has a defined `contextSize`, and show its value. (Turns appear only after they seal.)
-- **Optionality:** `contextSize` may be `undefined` (provider reported no per-step usage).
- Treat absent as "unknown" — render a placeholder, NOT `0`.
-
-## Not included yet (next step)
-
-The model's **max context-window limit** is a SEPARATE, later field — so a UI like
-`contextSize / limit` (e.g. `34,102 / 200,000`) can't show the denominator yet. For now show
-only the current size (e.g. "34,102 tokens in context"). "context size" = current usage;
-"context window" = the future limit (see GLOSSARY).
diff --git a/frontend-conversation-lifecycle-handoff.md b/frontend-conversation-lifecycle-handoff.md
deleted file mode 100644
index ca6de57..0000000
--- a/frontend-conversation-lifecycle-handoff.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# FE handoff — conversation lifecycle (tab persistence across devices)
-
-Courier this to `../frontend`. All changes are ADDITIVE — nothing existing breaks.
-
-## What shipped (backend)
-
-Conversations now have a lifecycle **status** field: `active`, `idle`, or `closed`.
-This enables tab persistence: when a new browser connects, it fetches all
-`active` + `idle` conversations and restores the tab bar.
-
-- **`active`** — an agent is currently generating (a turn is in-flight).
-- **`idle`** — conversation exists, not generating. User can send a message to resume.
-- **`closed`** — user dismissed the tab (hidden from the tab bar, not deleted).
-
-Status transitions are driven by the backend:
-- `idle → active` when a turn starts.
-- `active → idle` when a turn settles (done/error).
-- `→ closed` when `POST /conversations/:id/close` is called.
-
-## Bump pinned deps
-- `@dispatch/wire` → `0.10.0`
-- `@dispatch/transport-contract` → `0.14.0`
-
-## New types (`@dispatch/wire` + `@dispatch/transport-contract`)
-
-```ts
-export type ConversationStatus = "active" | "idle" | "closed";
-
-// ConversationMeta now has a status field:
-export interface ConversationMeta {
- readonly id: string;
- readonly createdAt: number;
- readonly lastActivityAt: number;
- readonly title: string;
- readonly status: ConversationStatus;
-}
-
-// New WS message (server → client):
-export interface ConversationStatusChangedMessage {
- readonly type: "conversation.statusChanged";
- readonly conversationId: string;
- readonly status: ConversationStatus;
-}
-```
-
-`ConversationStatusChangedMessage` is added to the `WsServerMessage` union.
-
-## `GET /conversations?status=active,idle` — filter by status
-
-The existing `GET /conversations` endpoint now accepts an optional `?status=`
-query param: a comma-separated list of statuses to filter by.
-
-- **Default (no param):** returns ALL conversations (all statuses).
-- `?status=active,idle` → only active + idle (what the FE tab bar wants).
-- `?status=closed` → only closed conversations (for a history view).
-- Invalid values are silently dropped. If all values are invalid, no filter
- is applied (returns all).
-
-## `POST /conversations/:id/close` — marks as closed
-
-The existing close endpoint now also sets the conversation's status to `closed`
-in the store. This persists across server restarts. The response is unchanged
-(`{ conversationId, abortedTurn }`).
-
-## `conversation.statusChanged` WS message
-
-Broadcast to ALL connected WS clients whenever a conversation's status changes.
-The backend emits this synchronously alongside the existing `turnStarted` /
-`turnSettled` / `conversationClosed` hooks.
-
-```ts
-{ type: "conversation.statusChanged", conversationId: "conv-1", status: "active" }
-```
-
-## What the FE needs to do
-
-1. **On connect:** call `GET /conversations?status=active,idle` to fetch
- conversations for the tab bar. Render tabs for each.
-
-2. **`active` tabs:** subscribe to the conversation's live stream
- (`chat.subscribe` WS op) to receive in-flight events.
-
-3. **`idle` tabs:** load history via `GET /conversations/:id`. No live
- subscription needed until the user sends a message.
-
-4. **Tab close button:** call `POST /conversations/:id/close` to mark the
- conversation as `closed`. Remove it from the tab bar.
-
-5. **Handle `conversation.statusChanged` WS messages:** update the tab's
- status indicator. When a conversation goes `idle → active`, show a
- loading/generating indicator. When it goes `active → idle`, remove the
- indicator. When it goes `closed`, remove the tab.
-
-6. **Closed conversations:** accessible from a history view
- (`GET /conversations?status=closed`). Can be reopened by sending a message
- (which transitions `closed → active`).
-
-## CLI
-
-`dispatch list` now defaults to `active,idle` (excludes closed). New flags:
-- `--status <active|idle|closed>` — filter by a single status.
-- `--all` — include closed (show all statuses).
diff --git a/frontend-conversation-list-handoff.md b/frontend-conversation-list-handoff.md
deleted file mode 100644
index dd3fd63..0000000
--- a/frontend-conversation-list-handoff.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# FE handoff — conversation list, title, and open tab
-
-Courier this to `../frontend`. All changes are ADDITIVE — nothing existing breaks.
-
-## What shipped (backend)
-
-Three new features for conversation management:
-
-1. **Conversation list** — `GET /conversations` returns all known conversations with
- metadata (id, title, createdAt, lastActivityAt). The backend auto-tracks metadata
- on every message append; title defaults to the first user message (truncated 80 chars).
-
-2. **Conversation title** — `GET/PUT /conversations/:id/title` lets the FE read and
- set a human-readable title for any conversation.
-
-3. **Open tab signal** — `POST /conversations/:id/open` broadcasts a `conversation.open`
- WS message to all connected clients (e.g. when the CLI uses `--open`). See also
- `frontend-conversation-open-handoff.md` for the WS message details.
-
-No version bumps needed — all types are already in `@dispatch/transport-contract` `0.13.0`
-and `@dispatch/wire` `0.9.0`.
-
-## `GET /conversations` — conversation list
-
-Returns all conversations sorted by `lastActivityAt` descending (most recent first).
-
-- Optional `?q=<prefix>` query param filters by conversation ID prefix (short-ID
- resolution — used by the CLI; the FE can ignore it or use it for search).
-- 200 response: `ConversationListResponse`
-
-```ts
-interface ConversationListResponse {
- readonly conversations: readonly ConversationMeta[];
-}
-
-interface ConversationMeta {
- readonly id: string;
- readonly createdAt: number; // epoch-ms
- readonly lastActivityAt: number; // epoch-ms
- readonly title: string;
-}
-```
-
-**FE use case:** render a conversation sidebar/picker showing title + relative time.
-Click a conversation to open it (load its history via the existing `GET /conversations/:id`).
-
-## `GET /conversations/:id/title` — read title
-
-- 200 response: `TitleResponse { conversationId, title }`
-
-## `PUT /conversations/:id/title` — set title
-
-- Body: `SetTitleRequest { title: string }` (non-empty after trim, else 400)
-- 200 response: `TitleResponse { conversationId, title }`
-
-**FE use case:** let the user rename a conversation. The title is also auto-set from
-the first user message, so a newly created conversation already has a title.
-
-## `GET /conversations/:id/last` — blocking last message
-
-Blocks server-side until any in-flight turn settles, then returns the last AI text
-message. Mainly for CLI use (`dispatch read <id>`), but the FE could use it for
-notifications or previews.
-
-- 200 response: `LastMessageResponse { conversationId, content, turnId? }`
-- `content` is empty string if the conversation has no assistant message.
-- Unknown conversation → `content: ""` (200, not an error).
-
-## `POST /conversations/:id/open` — signal frontend
-
-Calls the backend to broadcast `conversation.open` to all connected WS clients. See
-`frontend-conversation-open-handoff.md` for the WS message format and FE handling.
-
-- 200 response: `OpenConversationResponse { conversationId }`
-
-## What the FE needs to do
-
-1. **Bump pinned deps:** `@dispatch/wire` → `0.9.0`, `@dispatch/transport-contract`
- → `0.13.0`.
-
-2. **Conversation sidebar/picker:** call `GET /conversations` on load (and periodically
- or on focus) to show a list of conversations. Each entry shows `title` + relative
- time from `lastActivityAt`. Click to open → load history via `GET /conversations/:id`.
-
-3. **Title editing:** add an inline edit affordance on the conversation title.
- `PUT /conversations/:id/title` with `{ title }` to update.
-
-4. **Handle `conversation.open` WS message:** when a `"conversation.open"` message
- arrives, open (or focus) a tab for that `conversationId`. See
- `frontend-conversation-open-handoff.md`.
-
-## Notes
-
-- Conversations are **in-memory only** on the backend — the list resets on server
- restart. New conversations appear as users chat; old ones may disappear after a
- restart.
-- The title is auto-set from the first user message (truncated 80 chars). Users can
- override it via `PUT /conversations/:id/title`.
-- `createdAt` is set on the first message append; `lastActivityAt` updates on every
- append.
diff --git a/frontend-conversation-open-handoff.md b/frontend-conversation-open-handoff.md
deleted file mode 100644
index c4064ea..0000000
--- a/frontend-conversation-open-handoff.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# FE handoff — conversation.open (CLI --open flag)
-
-Courier this to `../frontend`. All changes are ADDITIVE.
-
-## What shipped (backend)
-
-The CLI's `--open` flag (`dispatch send <id> --text "..." --open`) calls
-`POST /conversations/:id/open`, which emits a `conversationOpened` bus event. The
-transport-ws extension subscribes and broadcasts a new WS message to ALL connected
-frontend clients.
-
-## New WS message (additive to `WsServerMessage`)
-
-```ts
-interface ConversationOpenMessage {
- readonly type: "conversation.open";
- readonly conversationId: string;
-}
-```
-
-The `type` is `"conversation.open"` — add it to the FE's `WsServerMessage` union
-handler. It arrives as a top-level WS message (not inside `chat.delta`).
-
-## What the FE needs to do
-
-1. **Handle the WS message** — when a `"conversation.open"` message arrives, open
- (or focus) a tab for `conversationId`. The backend just signals; the FE decides
- whether to actually open/focus or just notify.
-
-2. **Suggested behavior:**
- - If the conversation is already open in a tab, focus that tab.
- - If not, open a new tab for it (load its history via `GET /conversations/:id`).
- - Do NOT auto-focus if the user is actively typing in another tab (optional —
- your discretion).
-
-3. **No version bumps needed** — `@dispatch/transport-contract` already exports
- `ConversationOpenMessage` (additive to `WsServerMessage` since `0.13.0`). The FE
- just needs to add the `type: "conversation.open"` case to its WS message handler.
-
-## No other integration points
-
-- No new HTTP endpoints for the FE (the CLI calls `POST /conversations/:id/open`).
-- No new surface types.
-- No new `AgentEvent` types.
-- The message is a global broadcast (sent to ALL connected clients), not per-
- conversation.
-
-## Notes
-
-- The `--open` flag can be combined with `--queue` (enqueue + signal) or used
- without `--queue` (blocking send + signal).
-- Multiple clients (e.g. phone + laptop) all receive the broadcast — each decides
- independently whether to open/focus.
diff --git a/frontend-cr3-user-message-handoff.md b/frontend-cr3-user-message-handoff.md
deleted file mode 100644
index 0fb20e5..0000000
--- a/frontend-cr3-user-message-handoff.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# FE handoff — CR-3 fixed: user prompt is now on the turn's event stream
-
-Courier to `../frontend`. This resolves CR-3 from `backend-handoff.md` ("a watcher can't see
-the turn's USER prompt until seal"). **Option B implemented + live-verified.** Your staged-but-inert
-consumption can now be turned on.
-
-## What shipped (backend)
-
-A new **additive** `AgentEvent` variant carries the user prompt INTO the turn's outward stream:
-
-```ts
-// @dispatch/wire — added to the AgentEvent union
-interface TurnInputEvent {
- type: "user-message";
- conversationId: string;
- turnId: string;
- text: string; // the raw prompt, exactly as sent
-}
-```
-
-`session-orchestrator` emits it via the broadcast/buffer path as the **FIRST event of every turn**
-(before `turn-start`), so it is replayed to every subscriber — live AND late-join — and arrives on
-the HTTP/NDJSON path too. Persistence is unchanged (the user message is still appended atomically at
-seal); this only adds a buffered/broadcast event. Metrics are unaffected (it is not usage).
-
-## Version bumps (re-pin both)
-
-- `@dispatch/wire` **`0.5.0 → 0.6.0`** (additive union member).
-- `@dispatch/transport-contract` **`0.7.0 → 0.8.0`** (re-exports `AgentEvent`/`chat.delta`, which now
- carries `user-message`; no other transport-contract change).
-
-Re-mirror `.dispatch/{wire,transport-contract}.reference.md` and add `user-message` to the FE
-exhaustiveness guard.
-
-## FE action
-
-Flip on the already-staged `core/chunks` branch that folds a `user-message` event into a provisional
-user chunk for watchers, with your text dedup against the sender's optimistic echo. After re-pin:
-- a **pure watcher** (second device / `chat.subscribe` only) now shows the user bubble the moment the
- turn starts, not at seal;
-- the **sender** is unchanged (its optimistic echo dedups against the replayed `user-message`);
-- a **late-joiner** gets `user-message` first in the replay, then the rest of the in-flight turn.
-
-## Live-verified (backend, vs flash)
-
-Two WS clients on one conversation; client B subscribed but never sent. On A's `chat.send`, B received
-`chat.delta { event:{ type:"user-message", text:"…", turnId, conversationId } }` as its **first** delta
-(index 0), **before** `turn-sealed`, with `text` equal to A's prompt, then the streaming reply. `RESULT: OK`.
-
-## Note
-
-The ordering guarantee is: `user-message` is the first event of the turn, immediately followed by
-`turn-start`, then the usual deltas → `done` → `turn-sealed`. Treat `user-message` as turn-scoped
-(it carries `turnId`) so a multi-turn transcript attributes each prompt to its turn.
diff --git a/frontend-cwd-resolution-handoff.md b/frontend-cwd-resolution-handoff.md
deleted file mode 100644
index 80d5105..0000000
--- a/frontend-cwd-resolution-handoff.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Backend handoff — cwd resolution fixes (backend → FE) — courier doc
-
-> **From:** arch-rewrite orchestrator · **To:** frontend orchestrator (b18a) · **Courier:** the user.
-> Response to the cwd bug report you sent to backend agent ab13. The fixes are DONE and
-> live-verified on the dev stack.
-
-## Version bumps
-
-| Package | From | To | Notes |
-|---|---|---|---|
-| `@dispatch/wire` | — | — | **Unchanged** |
-| `@dispatch/transport-contract` | — | — | **Unchanged** |
-| `@dispatch/ui-contract` | — | — | **Unchanged** |
-
-**This is a behavior-only change.** No wire/transport-contract types changed. No FE re-pin or
-re-mirror needed. The FE needs NO contract change to benefit.
-
----
-
-## 1. The fix (what was broken → what now works)
-
-You reported: a workspace `defaultCwd` set, a conversation with no explicit cwd, and `pwd` ran in
-the server default (`process.cwd()`) instead of the workspace `defaultCwd`. Plus your desired
-behavior: a per-conversation cwd **relative to the workspace `defaultCwd`** unless absolute.
-
-**Root cause (backend-only):** the workspace-relative resolution lived in
-`conversation-store.getEffectiveCwd`, which only resolved the *persisted* cwd. But the FE sends the
-CwdField value as a **per-turn `cwd` on `chat.send`**, and `session-orchestrator` used a per-turn
-`cwd` **as-is** — bypassing `getEffectiveCwd` entirely. So a relative `cwd` like `"arch-rewrite"`
-reached `run_shell` raw → resolved against `process.cwd()` → a nonexistent path → `pwd` broke.
-
-**Three backend fixes (all live-verified):**
-
-1. **Per-turn `cwd` is now resolved.** `session-orchestrator` passes the per-turn `cwd` (on
- `chat.send`/`POST /chat` AND on manual `POST /chat/warm`) through `getEffectiveCwd` as an
- override, so it goes through the same workspace-relative algorithm as the persisted cwd.
-2. **New-conversation timing.** A brand-new conversation's first turn previously ran
- `getEffectiveCwd` *before* the workspace was assigned (so it saw `"default"`, not the request's
- workspace). Now the workspace is assigned first. A relative per-turn `cwd` on the FIRST message
- of a new conversation now resolves against the intended workspace.
-3. **`DELETE /conversations/:id/cwd` was a stub** (returned `{cwd:null}` but did NOT clear the
- persisted key). It now calls `clearCwd` and truly deletes the persisted cwd.
-
-## 2. The resolution algorithm (now applied to BOTH persisted and per-turn cwd)
-
-```
-workspaceId = persisted conversation workspaceId ("default" fallback)
-workspaceCwd = workspace.defaultCwd ?? null
-conversationCwd = the explicit cwd (persisted via GET /cwd, OR the per-turn chat.send cwd)
-
-if (conversationCwd == null) → workspaceCwd ?? serverDefaultCwd // process.cwd()
-else if (conversationCwd absolute) → conversationCwd // starts with "/"
-else → path.resolve(workspaceCwd ?? serverDefaultCwd, conversationCwd)
-```
-
-`serverDefaultCwd` = `process.cwd()` (the server's cwd).
-
-## 3. FE impact (minimal — no contract change)
-
-You do NOT need to change anything. Both FE patterns now work correctly:
-
-- **If you omit `cwd` on `chat.send`** (your current code): the backend resolves the persisted
- conversation cwd (set via `PUT /conversations/:id/cwd`) through the algorithm. ✅
-- **If you send a relative `cwd` on `chat.send`**: it is resolved against the workspace
- `defaultCwd`. ✅ (was broken — used raw)
-- **If you send an absolute `cwd`** (starts `/`): overrides outright. ✅
-
-### Endpoints (semantics — shapes unchanged)
-
-- `GET /conversations/:id/cwd` → **unchanged**: the RAW explicit conversation cwd (`null` =
- inheriting workspace default). Your CwdField shows what the user typed.
-- `GET /conversations/:id/lsp` → returns the **effective** (resolved) cwd. It now roots LSP at the
- effective cwd INCLUDING the server-default fallthrough (when neither conversation nor workspace
- cwd is set, LSP roots at `process.cwd()`). Previously returned `cwd: null` + empty `servers` when
- no cwd was set.
-- `DELETE /conversations/:id/cwd` → **now actually clears** the persisted cwd (was a no-op stub).
- Returns `{ conversationId, cwd: null }` (unchanged shape). Use this to reset a conversation's cwd
- to "inherit workspace default".
-- `PUT /conversations/:id/cwd` → unchanged (persists the raw value).
-
-## 4. Optional FE simplification (not required)
-
-You MAY now safely **omit `cwd` on `chat.send`** entirely and rely on the backend resolving the
-persisted conversation cwd (set via `PUT /conversations/:id/cwd`). This was the design you
-described in the original report. Either path (send cwd, or omit it) is correct; the backend
-resolves both consistently. Sending it is harmless; omitting it avoids sending redundant data.
-
-## 5. Live-verified (dev stack, workspace `test` defaultCwd `/home/tradam/projects/dispatch`)
-
-- Existing conversation, per-turn `cwd:"arch-rewrite"` → `pwd` = `/home/tradam/projects/dispatch/arch-rewrite` ✅
-- Brand-new conversation, per-turn `cwd:"arch-rewrite"` → `pwd` = `/home/tradam/projects/dispatch/arch-rewrite` ✅
-- Chat omitting `cwd` (persisted cwd `arch-rewrite`) → `pwd` = `/home/tradam/projects/dispatch/arch-rewrite` ✅
-- `PUT /tmp/test` → GET `/tmp/test` → DELETE → GET `null` (actually cleared) ✅
-
-`tsc -b` EXIT 0, biome clean, 1311 vitest pass.
diff --git a/frontend-history-windowing-handoff.md b/frontend-history-windowing-handoff.md
deleted file mode 100644
index 6792c38..0000000
--- a/frontend-history-windowing-handoff.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Backend → frontend handoff — CR-5: history windowing (`limit` / `beforeSeq`)
-
-> **From:** arch-rewrite · **To:** frontend · **Courier:** the user.
-> Reply to `backend-handoff-chat-limit.md` (CR-5). 2026-06-12. SHIPPED.
-
-## What shipped
-
-`GET /conversations/:id` now takes two OPTIONAL query params on top of
-`sinceSeq` (all combinable; authoritative spec = the
-`ConversationHistoryResponse` JSDoc in `@dispatch/transport-contract`):
-
-1. **`limit=<k>`** — returns only the NEWEST `k` chunks of the selection,
- still ASCENDING by seq. A selection with ≤ `k` chunks is returned whole
- (your `limit=192` against a short conversation gets the normal full
- response, exact). `limit` absent → exactly the previous behavior.
-2. **`beforeSeq=<s>`** — restricts the selection to `seq < s` (exclusive).
- Combined semantics: `sinceSeq < seq < beforeSeq`; with `limit`: the newest
- `k` chunks below `s`, ascending — your "Show earlier messages" page-in path.
-
-Your three flows, verbatim from your handoff, all work as written:
-
-- Fresh load: `?sinceSeq=0&limit=192`
-- Tail sync: `?sinceSeq=<maxCachedSeq>` (unchanged)
-- Page older in: `?beforeSeq=<oldestLoadedSeq>&limit=<ceil(L/4)>`
-
-## Ask #3 — our pick: the seq invariant, no new field (your "cheapest option")
-
-We CONFIRM IN WRITING, as a contractual guarantee (now codified in the
-`StoredChunk` doc in `@dispatch/wire` and referenced from the history-response
-doc): **per-conversation `seq` is 1-based, monotonic, and gap-free** — a
-conversation's first chunk is always `seq === 1` and numbering never skips.
-
-So derive it exactly as you proposed: `hasOlder = oldestLoaded.seq > 1`.
-There is deliberately NO `earliestSeq`/`hasOlder` response field.
-
-## Validation (new, only for the new params)
-
-`limit` and `beforeSeq` must be **positive integers** when present
-(`sinceSeq` keeps its existing semantics — `0` = from the start). Malformed,
-zero, or negative values → **HTTP 400 `{ error }`** (the error message names
-the offending param). Don't send `beforeSeq=0` — and you never need to:
-`oldestLoaded.seq === 1` already means there is nothing older.
-
-## `latestSeq` caveat (important for your cursor logic)
-
-`latestSeq` semantics are UNCHANGED (seq of the last returned chunk; the
-requested `sinceSeq` when the slice is empty) — but on a **windowed** read it
-describes the returned window, NOT the conversation's high-water mark:
-
-- A fresh `?sinceSeq=0&limit=192` load DID reach the true tail → `latestSeq`
- is a valid sync cursor.
-- A `?beforeSeq=...` backfill page did NOT → do not regress your tail cursor
- from a backfill response. (Your seq-keyed dedup cache makes this natural —
- just don't feed backfill `latestSeq` into the tail cursor.)
-
-## Versions (re-pin + re-mirror)
-
-- `@dispatch/transport-contract` **`0.9.0 → 0.10.0`** — the param/validation/
- caveat docs above (response TYPE shape unchanged; no new fields).
-- `@dispatch/wire` **`0.6.0 → 0.6.1`** — doc-only: the 1-based seq guarantee
- codified on `StoredChunk`.
-
-## Test coverage (backend, for your confidence)
-
-- conversation-store: +8 windowing tests (newest-N ascending, bounds,
- combined bounds, page-in, empty selection, garbage-in, no-window regression
- guard; the "gap-free 1-based seq" test now backs a written contract).
-- transport-http: +20 route/param tests incl. all five 400 validation cases
- and a no-params byte-identical regression guard.
-- Full suite: typecheck clean · biome clean · 935 vitest + 112 bun tests green.
diff --git a/frontend-lsp-cwd-handoff.md b/frontend-lsp-cwd-handoff.md
deleted file mode 100644
index e7c8417..0000000
--- a/frontend-lsp-cwd-handoff.md
+++ /dev/null
@@ -1,133 +0,0 @@
-# Frontend handoff — LSP status + per-conversation CWD
-
-> Backend milestone complete (this repo). The web frontend is a SEPARATE repo
-> (`../frontend`); this document is couriered to it by the user (ORCHESTRATOR
-> §7 — `lsp references` does not span repos). All types below are exported from
-> `@dispatch/transport-contract` (bumped to **0.5.0**).
-
-## TL;DR for the FE
-Two new capabilities are now on the backend:
-1. **Per-conversation working directory (cwd)** — get/set per tab (a tab = a
- `conversationId`). Persisted server-side; defaults a turn's cwd when `/chat`
- omits one.
-2. **Per-conversation LSP status** — which language servers are configured for a
- tab's cwd and whether each is connected.
-
-## Endpoints
-
-### `GET /conversations/:id/cwd` → `CwdResponse`
-```ts
-interface CwdResponse { conversationId: string; cwd: string | null }
-```
-`cwd` is `null` until set.
-
-### `PUT /conversations/:id/cwd` (body `SetCwdRequest`) → `CwdResponse`
-```ts
-interface SetCwdRequest { cwd: string }
-```
-- `200` with the new `CwdResponse` on success.
-- `400` `{ error }` if `cwd` is missing/empty.
-- Content-Type `application/json`. CORS now allows `PUT`.
-
-### `GET /conversations/:id/lsp` → `LspStatusResponse`
-```ts
-type LspServerState = "connected" | "starting" | "error" | "not-started";
-interface LspServerInfo {
- id: string; // "typescript", "luau-lsp"
- name: string; // display name
- root: string; // absolute workspace root the server is rooted at
- extensions: string[]; // e.g. [".ts",".tsx"] or [".luau"]
- state: LspServerState;
- error?: string; // present only when state === "error"
-}
-interface LspStatusResponse {
- conversationId: string;
- cwd: string | null; // the tab's persisted cwd
- servers: LspServerInfo[]; // [] when cwd is null
-}
-```
-
-## Behavior notes (important for UX)
-- **`GET /conversations/:id/lsp` lazily connects.** The first call for a cwd
- resolves the configured servers and **spawns + initializes** them, so it can take
- a moment (typically <1s; a cold luau-lsp loading Roblox types can take longer) and
- returns once each server reaches `connected`/`error`. Subsequent calls are fast
- (cached). Suggested UX: call it when a tab opens / cwd changes, show a spinner per
- server until `state` settles, then a connected/error badge.
-- **`servers` is empty when `cwd` is null** — prompt the user to set a cwd first.
-- **States:** `connected` = ready; `error` = failed to start (`error` has the
- reason, e.g. binary not found); `not-started`/`starting` = transient.
-- **cwd defaulting:** if a `/chat` (or `/chat/warm`) request omits `cwd`, the
- backend now uses the conversation's persisted cwd. If a request DOES send `cwd`,
- that value is used AND persisted (so the CLI `--cwd` keeps the stored value
- fresh). The FE's PUT and the chat `cwd` field write the same per-conversation
- store.
-
-## How servers are configured (so you can explain it to users)
-Per the tab's cwd, the backend resolves language servers from, in order:
-1. `<cwd>/.dispatch/lsp.json` (`{ servers: { <id>: { command, extensions,
- rootMarkers?, env?, initialization?, watch? } } }`)
-2. fallback `<cwd>/opencode.json` `lsp` key (opencode-compatible)
-3. a built-in `typescript` server (so a TS project works with zero config).
-No FE work needed for this — just display `LspStatusResponse`.
-
-## Operational note (surface to users on `state:"error"`)
-Language-server binaries must be on the **backend process's PATH**. A binary in a
-non-standard location (e.g. `~/.local/bin/typescript-language-server`) won't be
-found if the server daemon's PATH lacks that dir, yielding
-`state:"error", error:"ENOENT ... posix_spawn '<bin>'"`. luau-lsp
-(`/usr/local/bin`) and standard-PATH binaries work out of the box. Consider showing
-the `error` text directly so users can diagnose a missing/unfound binary.
-
-## Verified live
-- Roblox project (`luau-lsp`) → `connected` through the full HTTP path
- (`GET /conversations/:id/lsp`), using the project's existing `opencode.json` +
- an auto-spawned `rojo sourcemap --watch` sidecar.
-- This repo (`typescript`) → `connected`.
-- cwd PUT/GET round-trip → `200` + correct value.
-
-## Not in this slice (potential future FE asks)
-- A live WS surface for LSP status (currently HTTP-poll on tab open / cwd change).
-- An LSP-diagnostics stream pushed into the chat (the agent can pull diagnostics
- via the `lsp` tool today; auto-inject-on-write was deliberately deferred).
-
----
-
-## CONFIRMED — answers to `backend-handoff-cwd-lsp.md` (your 6 asks)
-
-> Re your courier doc. All six hold in the current backend. Code refs are
-> `packages/transport-http/src/app.ts` and `packages/session-orchestrator/src/
-> orchestrator.ts`. None require a backend change. **The draft → first-message cwd
-> path you built is fully supported.**
-
-| # | Your ask | Confirmed | Where |
-|---|----------|-----------|-------|
-| 1 | Unseen id: `GET /cwd` ⇒ `200 {cwd:null}`; `GET /lsp` ⇒ `200 {cwd:null,servers:[]}` (no 404/500) | ✅ | `getCwd` returns `null` for any id; `/lsp` early-returns `{cwd:null,servers:[]}` before touching the LSP — `app.ts:322-333, 364-372` |
-| 2 | `PUT /cwd` on an unseen/draft id persists (no prior turn/row) | ✅ | `setCwd` is a plain per-id upsert (key `conv:<id>:cwd`) — `app.ts:335-362` |
-| 3 | Draft cwd carries into turn 1 (`PUT D/cwd`, then `chat.send` D with no `cwd`) | ✅ | orchestrator uses the persisted cwd when the request omits it; same store key the PUT writes — `orchestrator.ts:122-125`. Unit-tested ("uses the persisted cwd when the request omits cwd") |
-| 4 | CORS **preflight** (`OPTIONS` + `Access-Control-Request-Method: PUT`) is answered | ✅ | global Hono `cors`, `allowMethods:["GET","POST","PUT","OPTIONS"]` applied to all routes — `app.ts:112-114`; preflight test passes |
-| 5 | No spawn when `cwd` is null | ✅ | `/lsp` returns `servers:[]` before calling the LSP service when `cwd===null` — `app.ts:367-372` |
-| 6 | Error body is `{ error: string }` | ✅ | every error path returns `{error}` (e.g. empty-cwd PUT ⇒ `400 {error:"Field 'cwd' is required and must be a non-empty string"}`) — `app.ts:342,346,350,360,376,400` |
-
-### Setting the cwd on the first message — two supported flows
-- **(a) Pre-set, then send (your flow):** `PUT /conversations/D/cwd {cwd}` on the
- client-minted draft id → then `POST /chat {conversationId:D}` **without** a `cwd`
- field → the turn loads and runs in the persisted `D` cwd.
-- **(b) cwd on the first `/chat`:** include `cwd` in the first `POST /chat` → it is
- used for that turn **and** persisted for subsequent turns.
-Both write/read the same per-conversation store, so they're interchangeable; a draft
-that has never sent a message works because the cwd store is independent of history.
-
-### One edge to be aware of (FE currently safe)
-`PUT /cwd` rejects an empty-string `cwd` (`400`), but the **`/chat` `cwd` field**
-does not — the orchestrator treats any non-`undefined` `cwd` as "provided", so a
-literal `cwd:""` on `/chat` would override the persisted cwd with empty. Your FE
-omits the field (sends `undefined`) on cwd-less sends, so this never triggers. **Keep
-omitting the field (don't send `cwd:""` / `cwd:null`)** when you want the persisted
-draft cwd to apply. (If preferred, the backend can harden this to treat empty/blank
-as "not provided" — say the word.)
-
-### Live-verified
-Unseen-id `GET /cwd` ⇒ `{cwd:null}`, `GET /lsp` ⇒ `{cwd:null,servers:[]}`,
-`PUT` round-trip `200`, and the empty-cwd `400 {error}` shape were all observed live;
-Roblox `luau-lsp` and this repo's `typescript` both reach `state:"connected"`.
diff --git a/frontend-lsp-cwd-workspace-handoff.md b/frontend-lsp-cwd-workspace-handoff.md
deleted file mode 100644
index d17f0a5..0000000
--- a/frontend-lsp-cwd-workspace-handoff.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# FE Courier Handoff: LSP cwd resolution fix + PUT cwd workspaceId
-
-> Backend→FE courier. The user couriers this to `../frontend` (FE agent `ffe3`).
-> No `@dispatch/wire` or `@dispatch/transport-contract` version bump is breaking —
-> the `SetCwdRequest.workspaceId` is additive (optional); the `LspStatusResponse.cwd`
-> semantics changed (was always non-null effective cwd; now null when no cwd set).
-
-## What changed (backend)
-
-### 1. `GET /conversations/:id/lsp` — behavior change
-
-**Before:** The endpoint called `getEffectiveCwd(conversationId)` directly. When no
-cwd was persisted, this fell through to the server default (`process.cwd()`) — so the
-LSP connected on the wrong directory (the server's cwd, not the conversation's
-workspace).
-
-**After:** The endpoint now gates on the **persisted** cwd (`getCwd`) first:
-- When no cwd is persisted → response is `{ cwd: null, servers: [] }` (HTTP 200, no
- LSP connection). The LSP does NOT connect when no working directory is set.
-- When a cwd IS persisted → the endpoint resolves the **effective** cwd (relative cwd
- resolved against the workspace `defaultCwd`; absolute → as-is) and returns
- `{ cwd: "<effectiveCwd>", servers: [...] }`.
-
-**FE impact:**
-- `LspStatusResponse.cwd` can now be `null` (previously it was always a string, even
- when no cwd was set — it returned `process.cwd()`). The FE should handle `null` by
- showing "no LSP connected" or "set a working directory."
-- When `cwd` is non-null, it is the RESOLVED (effective) cwd — an absolute path. The FE
- can display this as the directory the LSP is connected on.
-
-### 2. `PUT /conversations/:id/cwd` — new optional `workspaceId` field
-
-**Before:** The `PUT /conversations/:id/cwd` body was `{ cwd: string }` — only set
-the persisted cwd, no workspace assignment.
-
-**After:** The body now accepts an optional `workspaceId`:
-```json
-{ "cwd": "/home/user/project", "workspaceId": "my-team" }
-```
-
-When `workspaceId` is provided:
-1. The conversation is assigned to that workspace (via `ensureWorkspace` +
- `setWorkspaceId`) BEFORE the cwd is persisted.
-2. This ensures a subsequent `GET /conversations/:id/lsp` resolves a relative cwd
- against the workspace's `defaultCwd` (not the server default).
-3. Invalid `workspaceId` (not a valid slug: lowercase `[a-z0-9-]`, 1–40 chars) →
- HTTP 400 `{ error: "Invalid workspaceId" }`.
-
-When `workspaceId` is absent → behavior is unchanged (just `setCwd`).
-
-**FE action:** When the user sets the working directory on a new chat tab, send the
-`workspaceId` alongside the `cwd` in the `PUT /conversations/:id/cwd` request. This
-ensures the LSP resolves correctly even before the first turn.
-
-### Example flow (new chat tab)
-
-1. User opens a new chat tab (selects workspace "my-team" with
- `defaultCwd: "/home/tradam/projects/dispatch"`)
-2. User sets working dir to `"arch-rewrite"` (relative)
-3. FE sends: `PUT /conversations/abc/cwd { "cwd": "arch-rewrite", "workspaceId": "my-team" }`
-4. Backend: assigns conversation to workspace "my-team", then persists cwd "arch-rewrite"
-5. FE calls: `GET /conversations/abc/lsp`
-6. Backend: `getCwd("abc")` → `"arch-rewrite"` (non-null) → `getEffectiveCwd("abc")` →
- resolves "arch-rewrite" against workspace "my-team"'s `defaultCwd`
- (`"/home/tradam/projects/dispatch"`) → `"/home/tradam/projects/dispatch/arch-rewrite"`
-7. Response: `{ cwd: "/home/tradam/projects/dispatch/arch-rewrite", servers: [...] }`
-
-Without the `workspaceId` on the PUT (step 3), the conversation would be in the
-`"default"` workspace (defaultCwd: null), and the relative cwd "arch-rewrite" would
-resolve against `process.cwd()` — the wrong directory.
-
-## Contract version
-
-`@dispatch/transport-contract` bumped to `0.17.0` (additive: `SetCwdRequest.workspaceId`
-is optional; `LspStatusResponse.cwd` comment updated — no field type change).
diff --git a/frontend-mcp-status-handoff.md b/frontend-mcp-status-handoff.md
deleted file mode 100644
index d19a9f1..0000000
--- a/frontend-mcp-status-handoff.md
+++ /dev/null
@@ -1,117 +0,0 @@
-# Handoff — MCP Status Endpoint (backend + frontend)
-
-## Backend: `GET /conversations/:id/mcp` (transport-http)
-
-Mirror the existing `GET /conversations/:id/lsp` endpoint exactly. The contract
-types are already in `@dispatch/transport-contract` 0.22.0:
-
-```typescript
-export type McpServerState = "connecting" | "connected" | "error" | "disconnected";
-
-export interface McpServerInfo {
- readonly id: string;
- readonly state: McpServerState;
- readonly error?: string;
- readonly toolCount: number;
- readonly configSource?: string;
-}
-
-export interface McpStatusResponse {
- readonly conversationId: string;
- readonly cwd: string | null;
- readonly servers: readonly McpServerInfo[];
-}
-```
-
-### What to change in `packages/transport-http/`
-
-1. **`src/seam.ts`** — add re-exports from `@dispatch/mcp`:
- ```typescript
- export type { McpServerStatus, McpService } from "@dispatch/mcp";
- export { mcpServiceHandle } from "@dispatch/mcp";
- ```
-
-2. **`src/app.ts`** — add `mcpService?` to `CreateServerOptions` (optional, same
- as `lspService?`), then add the route:
- ```typescript
- app.get("/conversations/:id/mcp", async (c) => {
- // Mirror the LSP route exactly:
- // 1. Gate on persisted cwd (getCwd) — return {cwd:null, servers:[]} when null
- // 2. Resolve effective cwd (getEffectiveCwd) — return {cwd:null, servers:[]} when null
- // 3. If opts.mcpService === undefined → 503 { error: "MCP service not available" }
- // 4. Call opts.mcpService.status(effectiveCwd) → McpServerStatus[]
- // 5. Map McpServerStatus → McpServerInfo (id, state, error?, toolCount, configSource?)
- // 6. Return McpStatusResponse { conversationId, cwd: effectiveCwd, servers }
- });
- ```
-
-3. **`src/extension.ts`** — add `host.getService(mcpServiceHandle)` alongside
- `lspService`, and pass `mcpService` to `createApp({...})`.
-
-4. **`package.json`** — add `"@dispatch/mcp": "workspace:*"` to dependencies.
-
-5. **Tests** — mirror the LSP status tests:
- - Returns null+empty when no persisted cwd — `mcpService.status` NOT called.
- - Returns servers when cwd is set.
- - Returns 503 when `mcpService` is undefined.
- - Maps `McpServerStatus` → `McpServerInfo` correctly (error omitted when
- undefined, configSource omitted when undefined — honor `exactOptionalPropertyTypes`).
-
-### McpServerStatus → McpServerInfo mapping
-
-The `McpService.status(cwd)` returns `McpServerStatus[]` from `@dispatch/mcp`:
-```typescript
-interface McpServerStatus {
- readonly id: string;
- readonly state: "connecting" | "connected" | "error" | "disconnected";
- readonly error?: string;
- readonly toolCount: number;
-}
-```
-Map to `McpServerInfo` (same fields, conditionally include `error` per
-`exactOptionalPropertyTypes`). Note: `McpServerStatus` does NOT have
-`configSource` — that field is on `ResolvedMcpServer` (from config resolution).
-If you want to include `configSource` in the status response, the `McpService`
-interface or `McpServerStatus` would need to be extended. For Phase 2, omit
-`configSource` (it's optional on `McpServerInfo`) unless the MCP extension is
-updated to include it in the status.
-
----
-
-## Frontend (dispatch-web): consume `GET /conversations/:id/mcp`
-
-### What to do
-
-1. **Re-pin** `@dispatch/transport-contract` to `0.22.0`.
-2. **Re-mirror** the reference snapshot if one exists.
-3. **Add a fetch** for `GET /conversations/:id/mcp` — mirror how `GET /conversations/:id/lsp`
- is fetched and displayed.
-4. **Render** the MCP server status: each server's `id`, `state` (with the same
- connected/error/starting visual treatment as LSP), `toolCount`, and optional
- `error`.
-5. Place the MCP status UI alongside (or below) the LSP status in the conversation
- settings/panel — they're sibling features.
-
-### Response shape
-
-```json
-{
- "conversationId": "abc-123",
- "cwd": "/home/user/project",
- "servers": [
- {
- "id": "freecad",
- "state": "connected",
- "toolCount": 12
- },
- {
- "id": "chrome-devtools",
- "state": "error",
- "error": "Executable not found in $PATH: npx",
- "toolCount": 0
- }
- ]
-}
-```
-
-When no cwd is set: `{ "conversationId": "abc-123", "cwd": null, "servers": [] }`.
diff --git a/frontend-message-queue-handoff.md b/frontend-message-queue-handoff.md
deleted file mode 100644
index b9c2a6d..0000000
--- a/frontend-message-queue-handoff.md
+++ /dev/null
@@ -1,189 +0,0 @@
-# FE handoff — message queue + steering injection
-
-Courier this to `../frontend` (cross-repo contract change; `lsp references` does
-not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks.
-
-## What shipped (backend)
-
-A per-conversation **message queue** + **steering** feature. While a turn is
-GENERATING, a client can enqueue a user message onto the conversation's queue;
-it is delivered mid-turn as **steering** — injected at the next tool-result
-boundary so the model sees it alongside the tool results and can adjust course.
-If the turn ends with a non-empty queue (no tool call fired), the queue is
-carried into a NEW turn as its opening prompt.
-
-- **`message queue`** — the per-conversation buffer (owned by a new
- `@dispatch/message-queue` extension). Transient + in-memory; the queue is
- NOT on the chat stream — it is exposed to the frontend as a per-conversation
- SURFACE (see below).
-- **`steering`** — a user message injected into an in-flight turn at the
- tool-result boundary (drawn from the queue). Emitted on the chat stream as a
- new `steering` `AgentEvent` so it appears in the transcript live.
-
-Versions: `@dispatch/wire` `0.7.0 → 0.8.0`, `@dispatch/transport-contract`
-`0.11.0 → 0.12.0`. Bump the pinned `file:` deps. (`@dispatch/ui-contract` is
-unchanged — the queue uses the existing `custom` surface field kind.)
-
-## Wire types (in `@dispatch/wire`, re-exported by `@dispatch/transport-contract`)
-
-```ts
-/** A message held in the conversation's queue, awaiting steering delivery. */
-interface QueuedMessage {
- readonly id: string; // stable, client-visible (UI key + dedup)
- readonly text: string;
- readonly queuedAt: number; // epoch-ms
-}
-
-/** Payload of the message-queue surface's `custom` field (see below). */
-interface QueuePayload {
- readonly messages: readonly QueuedMessage[];
-}
-
-/** New `AgentEvent` variant (additive to the union). */
-interface TurnSteeringEvent {
- readonly type: "steering";
- readonly conversationId: string;
- readonly turnId: string;
- readonly text: string; // the combined text of all drained messages
-}
-```
-
-## How the frontend reads queue STATE: a surface (NOT the chat stream)
-
-The queue is control/state, so it rides the **surface** channel (like
-cache-warming), not the chat event stream. The `message-queue` extension
-contributes a per-conversation surface:
-
-- **Surface id:** `"message-queue"`; **scope:** `"conversation"` (subscribe with
- the `conversationId`).
-- **One `custom` field**, `rendererId: "message-queue"`, `payload: QueuePayload`
- (`{ messages: QueuedMessage[] }` — the current queue snapshot).
-- The surface updates (full new spec) on every change: enqueue (queue grew) and
- drain (queue emptied). An idle conversation's queue is empty → the field's
- `messages` is `[]`.
-
-So: **subscribe** to the `message-queue` surface per conversation and render
-the queue list from `payload.messages`. You need a bespoke renderer for
-`rendererId: "message-queue"` (the `custom` escape hatch — see the loaded-
-extensions `table` renderer precedent). The surface is **read-only** (no
-`invoke` actions); enqueuing is a chat op (below).
-
-## How the frontend ENQUEUES: the `chat.queue` WS op
-
-```ts
-interface ChatQueueMessage {
- readonly type: "chat.queue";
- readonly conversationId: string;
- readonly text: string;
-}
-```
-(additive to `WsClientMessage`.)
-
-- **Fire-and-forget.** On success the server emits NOTHING back — the
- `message-queue` SURFACE updates (the new message appears in the snapshot).
- On failure (empty/missing `text`, unknown conversation) the server replies
- `chat.error` (`{ type: "chat.error"; conversationId?; message }`).
-- **`text` must be non-empty** after trim (the server 400/errors otherwise).
-- **Auto-start when idle (server-owned decision):** if NO turn is active for the
- conversation, `chat.queue` does NOT queue — it STARTS A NEW TURN with the
- message as its opening prompt (equivalent to `chat.send`). The sender is
- auto-subscribed and the turn's events stream as `chat.delta`s (the opening
- `user-message` carries the text). So a single `chat.queue` op works for both
- "steer during generation" and "send" — you don't need to pick. When a turn IS
- active, the message is appended to the queue (surface updates) and delivered
- at the next tool-result boundary.
-
-## How the frontend shows steering in the TRANSCRIPT: the `steering` event
-
-When the kernel drains a non-empty queue at a tool-result boundary, the
-session-orchestrator emits a **`steering`** `AgentEvent` on the chat stream
-(arrives inside a `chat.delta` `{ event }`, like every other `AgentEvent`):
-
-```ts
-{ type: "chat.delta", event: { type: "steering", conversationId, turnId, text } }
-```
-
-- Render `text` as a **user bubble in the transcript**, positioned after the
- tool-call/tool-result it followed (it is a user message the model saw mid-turn,
- alongside the tool results). One `steering` event per drain; `text` is the
- combined text of all messages drained at that boundary (joined by a blank
- line).
-- **Move, don't duplicate:** the drained messages were already shown in the
- queue surface; when the surface then updates to empty (the drain cleared the
- queue), they should leave the queue UI (they now live in the transcript as the
- `steering` bubble). A simple rule: on `steering`, append the bubble to the
- transcript; the surface's subsequent empty snapshot clears the queue UI.
-- **Late-join safe:** like `user-message`, `steering` is buffered into the
- in-flight turn's event buffer, so a client that subscribes mid-turn (or a
- second device) sees it before seal (mirrors the CR-3 `user-message` fix).
- (Carry-to-new-turn, below, does NOT emit `steering` — the new turn's
- `user-message` covers it.)
-
-## Carry to a new turn (no `steering` event)
-
-If a turn ENDS with a non-empty queue (the model finished without making a tool
-call, so no tool-result boundary was hit), the orchestrator drains the queue,
-combines the messages, and **starts a NEW turn** whose opening prompt is the
-combined text. You will see: the old turn's `done` + `turn-sealed`, then a new
-`turn-start` + `user-message` carrying the combined text (rendered as the new
-turn's normal user bubble). The queue surface also clears (empty snapshot). No
-`steering` event in this case — handle the carried text as an ordinary new-turn
-user message.
-
-## HTTP path (for the CLI / non-WS clients; the FE uses the WS op above)
-
-`POST /conversations/:id/queue` with body `QueueRequest { text }` → `QueueResponse`:
-
-```ts
-interface QueueResponse {
- readonly conversationId: string;
- readonly startedTurn: boolean; // true = was idle, a new turn started
- readonly queue: readonly QueuedMessage[]; // snapshot after the enqueue
-}
-```
-- Empty/whitespace `text` → HTTP 400 `{ error }`.
-- `startedTurn: true` means no turn was active and the enqueue started one (the
- message is the turn's opening prompt, NOT a queued steering message).
-- `startedTurn: false` means a turn was active and the message was queued (the
- `queue` snapshot includes it).
-
-## What we need the FE to do
-
-1. **Bump pinned deps:** `@dispatch/wire` → `0.8.0`, `@dispatch/transport-contract`
- → `0.12.0`.
-2. **Queue UI (per conversation):** subscribe to the `message-queue` surface
- (scope `conversation`) and render `payload.messages` (`QueuedMessage[]`) with a
- `rendererId: "message-queue"` custom renderer — a list of pending messages
- with their text (and maybe `queuedAt` as a timestamp). Empty `messages` =
- nothing to show (hide the panel).
-3. **Enqueue affordance:** while a turn is generating, show an input that sends
- `chat.queue { conversationId, text }` (NOT `chat.send` — `chat.queue` is the
- steering entry; it auto-starts a turn if idle, so it's safe to offer it
- whenever the user wants to add input). Trim/validate non-empty client-side
- too; expect a `chat.error` on failure.
-4. **Steering bubble:** handle the new `steering` `AgentEvent` (type `"steering"`)
- on the `chat.delta` stream → render `event.text` as a user bubble in the
- transcript after the tool calls; clear the queue UI when the surface updates
- to empty.
-5. **Carry:** no special handling — a carried queue surfaces as a normal new
- turn (`turn-start` + `user-message`); just let the existing new-turn flow
- render it. The queue surface clears automatically.
-
-## Notes / known gaps
-
-- **Live end-to-end (a real steering turn via a tool-calling model) is not yet
- exercised** — the logic is unit/integration tested and the app boots clean with
- the `message-queue` extension registered, but a live `chat.queue` → tool-call
- → `steering` event flow against a real model has not been run. Worth a live
- smoke once the FE wires it (or ask the backend to run one).
-- **Close-with-queued-messages (open product question):** if a client
- `POST /conversations/:id/close` (explicit tab close) while the queue is
- non-empty, the in-flight turn aborts and the carry currently STILL fires
- (starting a new turn on the closed conversation). This may or may not be
- desired (does closing discard pending steering, or honor it?). Backend flag
- for a decision; if "discard on close" is wanted, the backend will gate the
- carry on `finishReason !== "aborted"`. No FE action either way — just be aware
- a closed conversation might briefly start a turn from a queued message.
-- **`steering` is additive** to the `AgentEvent` union — no exhaustive switches
- broke on the backend (verified: `tsc -b` EXIT 0). If the FE has an exhaustive
- switch on `AgentEvent`, add a `steering` case.
diff --git a/frontend-metrics-handoff.md b/frontend-metrics-handoff.md
deleted file mode 100644
index be033d8..0000000
--- a/frontend-metrics-handoff.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Frontend handoff — live turn metrics (tokens + timing)
-
-> From: arch-rewrite (backend) orchestrator · For: the frontend FE team.
-> Status: **LIVE on the stream now** (backend committed + live-verified). Consume via the pinned
-> contracts `@dispatch/[email protected]` + `@dispatch/[email protected]` (reference snapshots
-> regenerated in `dispatch-web/.dispatch/{wire,transport-contract}.reference.md`).
-
-## 1. What you can now access
-The backend's **authoritative** token + timing metrics are now on the live turn stream:
-
-| Metric | Where | Field(s) |
-|---|---|---|
-| Per-step tokens | `usage` event | `usage` (`inputTokens`/`outputTokens`/`cacheReadTokens?`/`cacheWriteTokens?`) + new `stepId?` |
-| Per-step **TTFT** | new `step-complete` event | `ttftMs?` |
-| Per-step **decode** time | new `step-complete` event | `decodeMs?` |
-| Per-step total generation | new `step-complete` event | `genTotalMs?` |
-| **Tool execution** time | `tool-result` event | `durationMs?` |
-| **Turn** wall-clock | `done` event | `durationMs?` |
-| **Turn** total tokens | `done` event | `usage?` |
-| **Tokens/sec** (TPS) | derive | `usage.outputTokens / (step-complete.decodeMs / 1000)` |
-| Context-size proxy | `usage` event | `usage.inputTokens` (size the model counted; `cacheReadTokens` = cached portion) |
-
-"Authoritative" = measured by the backend runtime, not client wall-clock. They differ from
-anything you'd time in the browser (no network/buffering in them).
-
-## 2. How they're delivered
-**Inline, in the same chat stream you already consume** — WS `chat.delta` frames (and the
-`POST /chat` NDJSON stream) carry the `AgentEvent` union; metrics are additional event types /
-fields in that union. **No new endpoint, no subscription/negotiation.** You already `switch` on
-`event.type`; route the metric events to a telemetry handler and ignore any you don't render
-(zero cost). They do **not** appear in message content — keep your transcript rendering as-is.
-
-These events are **low-frequency** (one `step-complete` per step, one `done` per turn, a
-`durationMs` per tool result) — not per-token — so there's no stream-volume concern.
-
-## 3. The new/changed events (shapes)
-All new fields are **optional** — see §5. Every event still carries `conversationId` + `turnId`.
-
-```ts
-// NEW variant in AgentEvent — emitted once per step, AT STEP END (timing is final here)
-interface TurnStepCompleteEvent {
- type: "step-complete";
- conversationId: string;
- turnId: string;
- stepId: StepId; // join key to the step's `usage` event + tool events
- ttftMs?: number; // time to first token (stream start → first text|reasoning delta)
- decodeMs?: number; // first token → stream end (== genTotalMs - ttftMs)
- genTotalMs?: number; // whole-step generation (present even if no first token was seen)
-}
-
-// usage event — now labeled by step
-interface TurnUsageEvent {
- type: "usage";
- conversationId: string; turnId: string;
- stepId?: StepId; // NEW — attribute tokens to a step / join to step-complete
- usage: Usage; // { inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens? }
-}
-
-// tool-result — now carries execution time
-interface TurnToolResultEvent {
- type: "tool-result";
- conversationId: string; turnId: string;
- stepId: StepId; toolCallId: string; toolName: string;
- content: string; isError: boolean;
- durationMs?: number; // NEW — tool execution time (dispatch → result)
-}
-
-// done — now carries turn totals
-interface TurnDoneEvent {
- type: "done";
- conversationId: string; turnId: string;
- reason: string;
- durationMs?: number; // NEW — whole-turn wall-clock
- usage?: Usage; // NEW — aggregate turn tokens (so you needn't sum the usage events)
-}
-```
-
-## 4. Correlation & derived metrics
-Keys: `turnId` groups a turn; `stepId` groups a step within it; `toolCallId` pairs a tool call
-with its result. A turn has **one `step-complete` (and usually one `usage`) per step**.
-
-- **Per-step TPS** = `usage.outputTokens / (step-complete.decodeMs / 1000)` — join `usage` and
- `step-complete` by `stepId`. (Use `decodeMs`, not `genTotalMs`, for decode-rate TPS; it excludes
- first-token latency. See "which TPS" caveat below.)
-- **Turn TPS** = `done.usage.outputTokens / (Σ step-complete.decodeMs / 1000)`.
-- **Generation total per step** = `genTotalMs` (or `ttftMs + decodeMs`).
-- **Turn-visible first-token latency** = the `ttftMs` of **step 0** (the first `step-complete`).
-- **Total prefill overhead** = `Σ ttftMs` across steps; **pure generation** = `Σ decodeMs`.
-- **Tool time** = `tool-result.durationMs` per call; sum per `stepId` for a batch.
-
-"Which TPS": `decodeMs` is first-token → end, so TPS over it is the decode rate (first-token
-latency removed). If you want end-to-end rate including the wait, use `ttftMs + decodeMs`.
-
-## 5. Optionality — you MUST tolerate absence
-- `step-complete` is always emitted per step, but its **timing fields are present only when the
- server runs with a clock** (it does in normal operation). `ttftMs`/`decodeMs` are additionally
- absent for a step that produced **no text/reasoning token** (e.g. a tool-call-only step) —
- `genTotalMs` is still present in that case.
-- `usage.stepId`, `tool-result.durationMs`, `done.durationMs`, `done.usage` are all optional.
-- Render gracefully when a value is missing (omit the figure; don't show `NaN`/`undefined`).
-
-## 6. What is NOT available yet (deferred — Pass 2)
-**Metrics are LIVE-ONLY.** They are **not persisted**, so:
-- `GET /conversations/:id` (history) returns messages/chunks but **no tokens/timing**. Reopening a
- past conversation will show content without metrics.
-- If you need historical metrics (e.g. show TPS on a reloaded conversation), that's the planned
- **Pass 2** (persist per-turn metrics + a read path) — see `tasks.md` "Pass 2 — DEFERRED". Tell
- us if you need it and we'll prioritize.
-- TPS is not sent pre-computed (derive it, §4). No per-token timing (metrics are per-step/per-turn).
-
-## 7. Integration checklist
-1. Refresh deps: `bun run typecheck` in frontend (picks up `[email protected]` / `[email protected]`).
-2. Extend your `chat.delta` event handler: add a `case "step-complete"` and read the new optional
- fields on `usage`/`tool-result`/`done`. (No exhaustive-switch break — these are additive.)
-3. Keep a per-turn (and per-step, keyed by `stepId`) telemetry accumulator alongside the transcript
- store; fold metric events into it; render where you want (e.g. a turn footer / per-step badges).
-4. Treat every metric field as optional (§5).
-
-## 8. Carrier facts (unchanged)
-HTTP 24203 (`POST /chat` NDJSON, `GET /conversations/:id`, `GET /models`), WS 24205 (one socket,
-`chat.delta` carries each `AgentEvent`), CORS `*`. Same events on both carriers.
diff --git a/frontend-metrics-pass2-handoff.md b/frontend-metrics-pass2-handoff.md
deleted file mode 100644
index adf0404..0000000
--- a/frontend-metrics-pass2-handoff.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# FE handoff — persisted replay metrics (Pass 2) + metrics endpoint
-
-> **Courier doc** (backend → `../frontend`, via the user). Per ORCHESTRATOR §7
-> the backend does NOT write the FE repo; the FE orchestrator applies this delta
-> on its side (regenerate the in-repo `.dispatch/*.reference.md` snapshots + bump
-> the `file:` dep). `lsp references` does not span the two repos. Backend commit:
-> `6db12ff`.
-
-## Versions
-- `@dispatch/wire` `0.3.0 → 0.4.0` (additive)
-- `@dispatch/transport-contract` `0.3.0 → 0.4.0` (additive)
-
-Pure-type, additive change — no breaking edits to existing types.
-
-## New wire types (`@dispatch/wire`, re-exported by `@dispatch/transport-contract`)
-
-```ts
-interface StepMetrics {
- stepId: StepId; // `<turnId>#<index>`, join key to the live stream
- usage: Usage; // { inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens? }
- ttftMs?: number; // time to first token (optional — clock + first-token gated)
- decodeMs?: number; // first token → stream end
- genTotalMs?: number; // stream start → end (== ttftMs + decodeMs when a first token was seen)
-}
-
-interface TurnMetrics {
- turnId: string; // plain wire turn id, join key to AgentEvents
- usage: Usage; // aggregate across all steps
- durationMs?: number; // turn wall-clock (optional — clock gated)
- steps: readonly StepMetrics[]; // per-step, in step order
-}
-```
-
-These are the **persisted, replayable** counterparts of the live `usage` /
-`step-complete` / `done` events (which remain transient and unchanged).
-
-## New read endpoint
-
-`GET /conversations/:id/metrics` → `ConversationMetricsResponse`:
-
-```ts
-interface ConversationMetricsResponse { turns: readonly TurnMetrics[] }
-```
-
-Semantics:
-- `turns` = every **sealed** turn's `TurnMetrics`, in **turn-append order**.
-- A turn appears only **after seal** (post-persist); an in-flight/unsealed turn is absent.
-- This is a **separate axis** from `GET /conversations/:id?sinceSeq=` (which returns
- seq-cursor chunk CONTENT). Metrics are keyed per **turn**, not per chunk, so they are
- **not** seq-filtered — hence a sibling route, not a field on the history response.
-- Unknown / metric-less conversation → `{ turns: [] }`.
-- CORS: same wildcard as the other routes.
-
-## Suggested FE consumption
-On (re)opening a conversation, the chat feature can `GET /conversations/:id/metrics`
-once alongside the history hydrate (`?sinceSeq=`), then render historical
-tokens/latency per turn (and per step via `stepId`) — identical fields to what it
-already routes from the live `step-complete` / `usage` / `done` stream. TPS is
-still derived FE-side (`usage.outputTokens / decodeMs`); context-size proxy =
-`usage.inputTokens`.
-
-## Invariants (confirmed live)
-- Persisted `TurnMetrics.usage` / `durationMs` and each `StepMetrics`
- (`stepId` + `usage` + `ttftMs`/`decodeMs`/`genTotalMs`) **byte-match** what the
- live stream emitted for the same turn (verified end-to-end against flash).
-- `stepId` is the SAME value on the live `step-complete`/`usage` events, the persisted
- `StepMetrics`, and the tool chunks — one grouping key across live + replay.
diff --git a/frontend-model-persistence-handoff.md b/frontend-model-persistence-handoff.md
deleted file mode 100644
index 912cea6..0000000
--- a/frontend-model-persistence-handoff.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Frontend handoff — per-conversation model persistence
-
-## What changed
-
-A chat's selected provider + model is now **persisted per conversation**
-(like `cwd` and `reasoningEffort` already are). Opening a conversation in a new
-browser session recalls the originally selected model instead of defaulting to
-the server default.
-
-## Contract version bump
-
-`@dispatch/transport-contract` `0.19.0 → 0.20.0` — re-pin the `file:` dep and
-re-mirror `.dispatch/transport-contract.reference.md`.
-
-## New types (additive)
-
-```ts
-// GET /conversations/:id/model
-export interface ModelResponse {
- readonly conversationId: string;
- readonly model: string | null; // <credentialName>/<model> form, or null
-}
-
-// PUT /conversations/:id/model
-export interface SetModelRequest {
- readonly model: string | null; // null clears the persisted selection
-}
-```
-
-## New endpoints
-
-### `GET /conversations/:id/model`
-Returns `ModelResponse`. `model` is `null` when never set (the server then
-resolves turns using the default provider + model).
-
-### `PUT /conversations/:id/model`
-Body: `SetModelRequest`. Set `model` to a `<credentialName>/<model>` string
-(one of the values from `GET /models`) to persist it. Set `model` to `null`
-to clear the persisted selection. Returns `ModelResponse` with the resulting
-value.
-
-## What the FE should do
-
-1. **On conversation open** — call `GET /conversations/:id/model` to fetch the
- persisted model. If non-null, set the model selector to that value. If null,
- use the global default (current behavior).
-
-2. **On model select** — call `PUT /conversations/:id/model` with the selected
- model name (`<credentialName>/<model>` form). This persists it so future
- turns (and new browser sessions) use the same model.
-
-3. **On model clear** (if the FE supports clearing back to default) — call
- `PUT /conversations/:id/model` with `{ model: null }`.
-
-4. **No `ChatRequest.model` change needed** — the FE may continue sending
- `model` on `chat.send` (per-turn override); the backend persists it. Or the
- FE may omit `model` on `chat.send` and rely on the persisted value — the
- backend resolves it. Either way works.
-
-## Backend behavior
-
-- **Per-turn override** (`ChatRequest.model` / `chat.send` model) takes
- precedence and is persisted.
-- **No per-turn override** → backend checks `getModel(conversationId)` → if
- non-null, uses it; if null, falls through to the default provider.
-- **Warm path** also resolves the model from persistence when no explicit
- override is given (parity with real turns).
-
-## No FE handoff needed for tasks 1 & 2
-
-- **Task 1** (workspace tab broadcast): already couriered to 29ae by a prior
- orchestrator agent (`frontend-workspace-open-handoff.md`).
-- **Task 2** (system-prompt cwd reconstruction): backend-only fix, no contract
- version bump, no FE action needed.
-
-## Assumptions made (user was away)
-
-1. **Persist the model name string** (`<credentialName>/<model>` form), not
- the provider/credential separately — the model name already encodes both
- (the credential binds to a provider). This mirrors how the CLI sends
- `--model` and how `ChatRequest.model` works.
-2. **No model validation on PUT** — the backend doesn't validate the model
- name on `PUT /conversations/:id/model` (it's just a string). The provider
- resolves it at turn time; an unknown model → turn error, not a 400. This
- matches the contract doc on `SetModelRequest`.
-3. **Empty string clears** — `setModel(id, "")` deletes the key. The HTTP
- `PUT` with `{ model: null }` maps to this. This is an implementation detail
- the FE doesn't need to know about (it sends `null`).
-4. **No `model` field on `ConversationMeta`** — following the precedent of `cwd`
- and `reasoningEffort` (which are NOT on `ConversationMeta` but fetched via
- dedicated endpoints). The FE calls `GET /conversations/:id/model` to read.
diff --git a/frontend-reasoning-effort-handoff.md b/frontend-reasoning-effort-handoff.md
deleted file mode 100644
index 656dede..0000000
--- a/frontend-reasoning-effort-handoff.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# FE handoff — reasoning effort (thinking-depth knob)
-
-Courier this to `../frontend` (cross-repo contract change; `lsp references` does not
-span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks.
-
-## What shipped (backend)
-
-A new user-settable knob, **reasoning effort**: how much extended thinking the model spends
-before answering. Canonical ladder (type `ReasoningEffort`, exported by `@dispatch/wire` and
-re-exported by `@dispatch/transport-contract`):
-
-```ts
-type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
-```
-
-Versions: `@dispatch/wire` `0.6.1 → 0.7.0`, `@dispatch/transport-contract`
-`0.10.0 → 0.11.0`. Bump the pinned `file:` deps.
-
-It has TWO setting scopes, resolved server-side per turn:
-
-1. **Per-turn override** — optional `reasoningEffort` on `ChatRequest` (HTTP `POST /chat`)
- and therefore on the WS `chat.send` message (`ChatSendMessage extends ChatRequest`).
- Applies to THAT turn only; does NOT persist.
-2. **Persisted per-conversation setting** — sticky; used for every turn that has no per-turn
- override:
- - `GET /conversations/:id/reasoning-effort` → `ReasoningEffortResponse`
- `{ conversationId, reasoningEffort: ReasoningEffort | null }` (`null` = never set).
- - `PUT /conversations/:id/reasoning-effort` with body `SetReasoningEffortRequest`
- `{ reasoningEffort }` → persists it.
-
-**Resolution chain (server-owned — do not re-implement):** per-turn override → persisted
-conversation value → **default `"high"`**. So a conversation with nothing set already runs at
-`high`; `null` from the GET means "default (`high`) applies", not "off".
-
-**Validation:** an unrecognized level → HTTP 400 `{ error }` (the error message lists the
-valid levels). Same for the WS path (the standard `chat.send` error reply). Send only the
-five ladder strings; omit the key entirely for "no override" (don't send `null`/`""`).
-
-## What the model does with it (context for UX copy)
-
-The Anthropic provider maps the level to an extended-thinking token budget
-(`low` 4 096 · `medium` 10 240 · `high` 16 384 · `xhigh` 32 768 · `max` 65 536). Higher
-levels = the model thinks longer before answering (more `reasoning-delta` events / thinking
-chunks ahead of the text — the FE already renders those). Providers without a thinking knob
-ignore the field — sending it is always safe.
-
-## What we need the FE to do
-
-1. **Per-conversation effort selector** — a 5-option control (plus an implicit "default"
- state when the GET returns `null`):
- - On conversation open: `GET /conversations/:id/reasoning-effort`; render `null` as
- "high (default)".
- - On change: `PUT` the chosen level. It takes effect from the NEXT turn — no turn restart
- needed.
-2. **(Optional) per-turn override** — if the composer grows a "think harder for this one
- message" affordance, set `reasoningEffort` on that `chat.send` only. The persisted setting
- is untouched by overrides.
-3. **Expect more thinking** — at `xhigh`/`max` the pre-answer thinking phase can be long;
- whatever spinner/" thinking…" treatment exists should tolerate extended runs of
- reasoning deltas before the first text delta.
-
-## Cache note (don't surprise users)
-
-Changing the effort level changes the provider request shape, which can bust the prompt
-cache for the next turn (one-time re-prefill cost). The backend's cache-warming path already
-warms with the SAME resolved effort as a real turn, so a STABLE setting stays cache-safe;
-only the act of changing it costs. If the FE wants, it can mention this in the selector's
-tooltip — no functional handling required.
-
-## Verify (manual)
-
-```bash
-# sticky setting round-trip
-curl -s localhost:24203/conversations/<id>/reasoning-effort # → null first time
-curl -s -X PUT localhost:24203/conversations/<id>/reasoning-effort \
- -H 'content-type: application/json' -d '{"reasoningEffort":"xhigh"}'
-curl -s localhost:24203/conversations/<id>/reasoning-effort # → "xhigh"
-# bad level → 400
-curl -s -X PUT localhost:24203/conversations/<id>/reasoning-effort \
- -H 'content-type: application/json' -d '{"reasoningEffort":"banana"}'
-```
diff --git a/frontend-stop-generation-handoff.md b/frontend-stop-generation-handoff.md
deleted file mode 100644
index 117b65e..0000000
--- a/frontend-stop-generation-handoff.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# FE handoff — stop generation mid-turn
-
-Courier this to `../frontend`. All changes are ADDITIVE.
-
-## What shipped (backend)
-
-A "stop" button: aborts an in-flight generation without closing the conversation.
-The conversation stays open — it transitions `active → idle` via the normal
-turn-settle path. Partial messages are persisted. The turn seals with
-`finishReason: "aborted"`.
-
-This is distinct from `POST /conversations/:id/close` which marks the
-conversation as `closed` (tab dismiss).
-
-## `POST /conversations/:id/stop` — stop generation
-
-Aborts the in-flight turn's `AbortController`. The kernel finishes generation,
-persists partial messages, and seals the turn normally. The conversation
-transitions `active → idle` (not `closed`).
-
-- 200 response: `{ conversationId: string, abortedTurn: boolean }`
-- `abortedTurn: true` — a turn was active and has been aborted.
-- `abortedTurn: false` — no active turn (no-op, conversation was already idle).
-- Idempotent — stopping an idle conversation is safe.
-
-## What the FE receives after stopping
-
-The existing event flow handles everything — no new WS message needed:
-
-1. The `done` event arrives with `reason: "aborted"` (the turn sealed normally).
-2. The `conversation.statusChanged` WS message arrives with `status: "idle"`.
-3. The FE should reload history via `GET /conversations/:id` to see the partial
- messages that were persisted before the abort.
-
-## What the FE needs to do
-
-1. **Stop button** in the conversation toolbar (only visible when `status: "active"`).
- On click → `POST /conversations/:id/stop`. Disable the button after clicking
- (wait for the `done` event + `statusChanged: idle` before re-enabling).
-
-2. **Handle the response**: `abortedTurn: true` means the stop worked.
- `abortedTurn: false` means there was nothing to stop (the turn may have
- already finished between the click and the request).
-
-3. **Reload history** after receiving the `done` event to show partial output.
-
-## CLI
-
-`dispatch stop <conversationId>` — stops generation. Resolves short IDs.
diff --git a/frontend-system-prompt-handoff.md b/frontend-system-prompt-handoff.md
deleted file mode 100644
index c135145..0000000
--- a/frontend-system-prompt-handoff.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# FE Courier Handoff: System Prompt Builder (Updated)
-
-> Backend→FE courier. Send to FE agent `ffe3`.
-> Supersedes the earlier `frontend-system-prompt-handoff.md` — adds `prompt:workspace_id`.
-
-## API endpoints
-
-### `GET /system-prompt` → `{ template: string }`
-Returns the current global template. When none is stored, returns the built-in default.
-
-### `PUT /system-prompt` ← `{ template: string }` → `{ template: string }`
-Set the global template. Empty string = "no system prompt". 400 if `template` missing/wrong type. 503 if service unavailable.
-
-### `GET /system-prompt/variables` → `{ variables: SystemPromptVariable[] }`
-Static catalog — always available (no service dependency). Use this to render the variable selector buttons.
-
-## Template format
-
-### Variable insertion
-```
-[type:name]
-```
-Resolves at construction time. Unknown type → blank. Non-existent variable (e.g. file not found) → blank.
-
-### Conditional blocks
-```
-[if type:name]
- ...if variable exists...
-[else]
- ...if not...
-[endif]
-```
-Negated:
-```
-[if !type:name]
- ...if variable does NOT exist...
-[endif]
-```
-Nested `[if]`: supported. Multi-line: supported. Unmatched `[if]`/`[endif]`: literal text.
-
-## Available variables (updated)
-
-| Type:Name | Description | Dynamic? |
-|---|---|---|
-| `system:time` | Current time (ISO 8601) | No |
-| `system:date` | Current date (YYYY-MM-DD) | No |
-| `system:os` | Operating system | No |
-| `system:hostname` | Machine hostname | No |
-| `prompt:cwd` | Working directory | No |
-| `prompt:model` | Current model name | No |
-| `prompt:conversation_id` | Conversation ID | No |
-| `prompt:workspace_id` | Workspace identifier — lets the AI know which workspace it's in, useful when summoning agents | No |
-| `git:branch` | Current git branch | No |
-| `git:status` | Short git status | No |
-| `file:<path>` | File contents (relative to cwd, or absolute if starts `/`) | **Yes** |
-
-For `file:<path>`, allow free-text input for the path.
-
-## Caching behavior
-
-System prompt is **constructed once** (first turn of a new conversation) and **persisted**. Reused on all subsequent turns (cache-safe). Reconstructed only on **compaction**. Changing the template does NOT affect existing conversations until compacted.
-
-## Default template
-
-```
-You are a helpful coding assistant.
-
-[if file:AGENTS.md]
-[file:AGENTS.md]
-[endif]
-
-The current working directory is [prompt:cwd].
-```
diff --git a/frontend-todo-handoff.md b/frontend-todo-handoff.md
deleted file mode 100644
index 4a81296..0000000
--- a/frontend-todo-handoff.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# FE handoff — todo task list surface
-
-Courier this to `../frontend` (cross-repo contract change; `lsp references` does
-not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks.
-
-## What shipped (backend)
-
-A per-conversation **task list** the AI model maintains via a `todo_write` tool. The
-list is exposed to the frontend as a per-conversation **surface** (read-only). The
-model creates/updates the list during a turn; the surface updates live so the FE can
-render the current state.
-
-- **`todo_write` tool** — the model passes the FULL list each call (replaces the
- existing list). Returns the list as JSON. The tool description guides the model on
- when to use it (3+ step tasks, planning, etc.).
-- **State** — in-memory, per-conversation. No persistence (the list lives for the
- process lifetime of the conversation).
-- **No new wire types, no version bumps.** The todo surface uses the existing
- `custom` surface field kind (`ui-contract` unchanged). The `TodoItem` type is
- defined by the `todo` extension and carried in the surface payload — it is NOT
- in `@dispatch/wire` or `@dispatch/transport-contract`.
-
-## The surface
-
-The `todo` extension contributes a per-conversation surface:
-
-- **Surface id:** `"todo"`
-- **Scope:** `"conversation"` (subscribe with the `conversationId`)
-- **Region:** `"side"`
-- **Title:** `"Tasks"`
-- **One `custom` field**, `rendererId: "todo"`, `payload: TodoPayload`
-
-```ts
-interface TodoPayload {
- todos: readonly TodoItem[];
-}
-
-interface TodoItem {
- content: string;
- status: "pending" | "in_progress" | "completed" | "cancelled";
-}
-```
-
-- **Read-only** — no `invoke` actions. The model mutates the list via the
- `todo_write` tool; the FE only renders.
-- **Updates** on every `todo_write` call (subscriber-notify → full new spec with the
- updated `todos` array).
-- **Empty list** — an idle conversation (no todo list created yet, or the model
- cleared it with an empty array) renders `todos: []`. Hide the panel when empty.
-
-## What the FE needs to do
-
-1. **Subscribe** to the `todo` surface per conversation (same pattern as
- `message-queue` and `cache-warming` — `scope: "conversation"`, pass
- `conversationId` on subscribe).
-
-2. **Custom renderer** for `rendererId: "todo"` — render the `payload.todos` array
- as a task list. Suggested UI:
- - Each item shows `content` with a status indicator:
- - `pending` — empty circle / checkbox
- - `in_progress` — spinner / filled circle (highlight)
- - `completed` — checkmark (strikethrough or dim the content)
- - `cancelled` — X / dash (dim/strikethrough)
- - Order is significant — items are in the order the model provided them (array
- index = identity).
- - Only one item should be `in_progress` at a time (the tool description enforces
- this via guidance, not validation — but the model should comply).
-
-3. **Live updates** — the surface pushes a new spec on every `todo_write` call. No
- polling needed. Just re-render from the new `payload.todos`.
-
-4. **Empty state** — when `todos` is `[]`, hide the panel (the model hasn't created
- a list yet, or cleared it).
-
-## No other integration points
-
-- No new WS ops (no `chat.queue` equivalent — the model is the only writer).
-- No new HTTP endpoints (the list is tool-driven, not API-driven).
-- No new `AgentEvent` types (the list is not on the chat stream).
-- No version bumps in `@dispatch/wire` or `@dispatch/transport-contract`.
-
-## Notes
-
-- **In-memory only** — the todo list does NOT persist across server restarts. If
- the server restarts, the list is cleared. The model recreates it on the next
- `todo_write` call. This mirrors the message-queue behavior.
-- **Per-conversation** — each conversation has its own list. Switching conversations
- means subscribing to a different `conversationId` and rendering that conversation's
- list.
-- **Model-driven** — the FE has no control over the list (read-only surface). The
- model creates, updates, and clears items. The FE just displays the current state.
diff --git a/frontend-turn-continuity-handoff.md b/frontend-turn-continuity-handoff.md
deleted file mode 100644
index e0be4a3..0000000
--- a/frontend-turn-continuity-handoff.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# FE handoff — turn continuity + multi-client live view
-
-Courier to `../frontend` (cross-repo; `lsp references` does not span repos —
-ORCHESTRATOR §7). Backend is implemented + live-verified against flash. This unblocks
-the "turn keeps running when the browser is backgrounded/reloaded" + "watch the same
-chat from a second device" behavior.
-
-## What changed in the backend (principle now enforced)
-
-A turn is **no longer bound to the WebSocket connection**. It runs to completion on the
-server regardless of any client, and **any number of connections can watch the same
-conversation's live events** — including a client that connects mid-turn (late-join
-replay). The old behavior (socket close → `AbortController.abort()` → turn killed) is
-gone.
-
-## New WS protocol (additive — `@dispatch/transport-contract` `0.6.0 → 0.7.0`)
-
-Two new client→server messages on the existing socket:
-
-```ts
-{ type: "chat.subscribe"; conversationId: string } // start watching a conversation's turns
-{ type: "chat.unsubscribe"; conversationId: string } // stop watching (does NOT stop the turn)
-```
-
-Server→client is UNCHANGED: turn events still arrive as
-`{ type: "chat.delta", event: AgentEvent }` (and `{ type: "chat.error", ... }`). Both
-replayed and live events use `chat.delta`.
-
-Semantics:
-- **`chat.subscribe`** registers this connection to receive the conversation's turn
- events. If a turn is in-flight, the server immediately **replays that turn's events so
- far** (from its `turn-start`) as `chat.delta`, then streams live ones. If idle, nothing
- is replayed (rely on the history read).
-- **`chat.send`** still starts a turn AND **auto-subscribes the sending connection** — so
- the sender needs no separate `chat.subscribe`. (If a turn is already generating for that
- conversation, the server replies `chat.error` "a turn is already generating…" and you
- stay subscribed to watch the running one.)
-- **`chat.unsubscribe`** / socket close → the server drops this connection's subscription
- but **never stops the turn**.
-- Subscriptions **persist across turns** on the backend: subscribe once and you receive
- every subsequent turn on that conversation until you unsubscribe/close.
-
-## What the FE must change (from the FE investigation)
-
-1. **On WS (re)connect — re-subscribe chat, not just surfaces.** Today `onReopen`
- (`src/app/store.svelte.ts`) only re-sends *surface* subscriptions. It must ALSO, for
- every open conversation, send `chat.subscribe { conversationId }`. This is what makes a
- backgrounded/reconnected client re-attach to a still-running turn and resume live
- streaming. (Pair it with a `syncTail()` so any turn that sealed while you were gone is
- committed from history.)
-2. **On page load — subscribe each restored tab's conversation** (in addition to the
- existing IndexedDB + `GET /conversations/:id?sinceSeq=` rehydrate). After a reload
- mid-turn you'll get the in-flight turn replayed and can keep rendering it live.
-3. **Render a real "running" state.** Derive it from the stream: a `turn-start` (or any
- delta) with no matching `done`/`turn-sealed` yet = generating. Today the Composer status
- is hard-wired idle and the `status` AgentEvent is a no-op reducer — wire it up so a
- watching device shows "generating…".
-4. **Don't lose a missed `turn-sealed`.** If you reconnect after the turn sealed while you
- were away, you won't get a live `turn-sealed`; `syncTail()` on (re)connect (point 1)
- commits the finished turn from history. If you reconnect WHILE it's still running, the
- replay + live tail carry you to the real `turn-sealed`.
-5. **Multi-device handoff (the goal):** opening the same conversation on device B is just
- `chat.subscribe { conversationId }`. B will see the in-flight turn (replayed) and watch
- it finish — even if device A (the sender) closed. No special handling beyond points 1–3.
-
-## Out of scope (backend will NOT do these yet)
-
-- **Per-step persistence / crash-resume:** if the backend PROCESS crashes mid-turn, the
- in-flight turn is still lost (the in-flight buffer is in-memory; only sealed turns are
- persisted). Reconnecting to a *running* turn works; surviving a *backend crash* mid-turn
- does not. Separate durability milestone (R1).
-- **Concurrent-send arbitration:** sending from two devices at once is not handled (by
- product decision — won't happen). A second `chat.send` while generating gets a
- `chat.error`.
-- **Explicit "stop generating":** there is no stop op (disconnect no longer stops a turn).
- A future `chat.stop` would be deliberate.
-
-## Quick manual check (mirrors the backend live test)
-
-Open two WS connections, `chat.subscribe` the same `conversationId` on both, `chat.send`
-on one → both receive identical `chat.delta` streams. Close the sender mid-turn → the other
-keeps receiving through `done`. Connect a third mid-turn + `chat.subscribe` → it receives
-`turn-start` replayed then the rest.
diff --git a/frontend-workspace-open-handoff.md b/frontend-workspace-open-handoff.md
deleted file mode 100644
index 8005a2f..0000000
--- a/frontend-workspace-open-handoff.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Frontend handoff — workspace id on conversation.open / statusChanged
-
-## What changed
-
-The backend now resolves the conversation's actual persisted workspace id and
-includes it on the WS broadcast for both `conversation.open` and
-`conversation.statusChanged`.
-
-- `@dispatch/transport-contract` `0.18.0 → 0.19.0` — additive `workspaceId: string`
- on both `ConversationOpenMessage` and `ConversationStatusChangedMessage`.
-- The backend uses the conversation's stored workspace (`"default"` fallback),
- not the per-turn start option.
-
-## What the frontend must do
-
-1. **Re-pin the `file:` dep** on `@dispatch/transport-contract` from the backend
- repo once this commit lands.
-2. **Re-mirror `.dispatch/transport-contract.reference.md`** to match the `0.19.0`
- contract.
-3. **Parser update** (`src/adapters/ws/logic.ts:116-123`): parse `workspaceId`
- from the incoming `conversation.open` and `conversation.statusChanged` messages.
-4. **`openConversation()`** (`src/app/store.svelte.ts:588-600`): use the message's
- `workspaceId` to stamp/focus the tab instead of `activeWorkspaceId` (the
- viewer's current workspace). This fixes the bug where a tab opened via the
- CLI `--open --workspace my-ws` was appearing in every workspace.
-5. **`onConversationStatusChanged()`** (`src/app/store.svelte.ts:703-718`): same
- fix when the FE calls `openConversation(conversationId)` on a status change and
- has no existing tab — use the `workspaceId` from the message.
-6. **Tests** (`logic.test.ts`, `index.test.ts`, `App.test.ts`, `conformance.test.ts`):
- update fixtures/assertions to carry `workspaceId`.
-
-## Backend status
-
-- `@dispatch/transport-contract`: `0.19.0` with additive `workspaceId`.
-- `session-orchestrator`: payload types widened; status-change emits resolve
- workspace id from store.
-- `transport-ws`: broadcasts include `workspaceId`.
-- `transport-http`: `POST /conversations/:id/open` resolves workspace id and emits
- it.
-- Verified: `tsc -b` EXIT 0, biome clean, **1405 vitest** green.
-
-## Note on assumptions
-
-I'm working autonomously while the user is away. Every assumption I make is
-recorded in `notes/assumptions-log.md` in this repo. Please record any
-assumptions you make while implementing this handoff in your own assumptions log
-so we can raise them when the user returns.
diff --git a/frontend-workspaces-handoff.md b/frontend-workspaces-handoff.md
deleted file mode 100644
index 1fed1bf..0000000
--- a/frontend-workspaces-handoff.md
+++ /dev/null
@@ -1,216 +0,0 @@
-# Backend handoff — Workspaces (backend → FE) — courier doc
-
-> **From:** arch-rewrite orchestrator · **To:** frontend orchestrator · **Courier:** the user.
-> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes
-> the backend will implement. The FE should re-pin `@dispatch/wire` and
-> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`.
-
-## Version bumps
-
-| Package | From | To | Notes |
-|---|---|---|---|
-| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` |
-| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops |
-| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** |
-
----
-
-## 1. Final types — `@dispatch/[email protected]`
-
-```ts
-/**
- * A named, URL-driven grouping of conversations that owns a default cwd.
- * Every conversation belongs to exactly one workspace; conversations that
- * haven't set their own per-conversation cwd inherit `defaultCwd`.
- */
-export interface Workspace {
- /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
- readonly id: string;
- /** Display title (editable). Defaults to `id` on creation. */
- readonly title: string;
- /** The workspace's default cwd, or `null` (fall through to server default). */
- readonly defaultCwd: string | null;
- /** Epoch-ms when the workspace was first created. */
- readonly createdAt: number;
- /** Epoch-ms of the most recent conversation activity in this workspace. */
- readonly lastActivityAt: number;
-}
-
-/**
- * A workspace entry in the list response — a `Workspace` plus a conversation count.
- */
-export interface WorkspaceEntry extends Workspace {
- /** Number of conversations assigned to this workspace. */
- readonly conversationCount: number;
-}
-```
-
-`ConversationMeta` gains a required `workspaceId`:
-
-```ts
-export interface ConversationMeta {
- readonly id: string;
- readonly createdAt: number;
- readonly lastActivityAt: number;
- readonly title: string;
- readonly status: ConversationStatus;
- /** Always present; "default" for legacy/unspecified conversations. */
- readonly workspaceId: string;
- readonly compactedFrom?: string;
-}
-```
-
----
-
-## 2. Final types — `@dispatch/[email protected]`
-
-### Additive fields on existing request types
-
-```ts
-export interface ChatRequest {
- readonly conversationId?: string;
- readonly message: string;
- readonly model?: string;
- readonly cwd?: string;
- readonly reasoningEffort?: ReasoningEffort;
- /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */
- readonly workspaceId?: string;
-}
-
-export interface QueueRequest {
- readonly text: string;
- /** Default "default". Auto-creates if missing. */
- readonly workspaceId?: string;
-}
-
-export interface ChatQueueMessage {
- readonly type: "chat.queue";
- readonly conversationId: string;
- readonly text: string;
- /** Default "default". Auto-creates if missing. */
- readonly workspaceId?: string;
-}
-```
-
-### Workspace endpoint types
-
-```ts
-/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */
-export interface EnsureWorkspaceRequest {
- /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */
- readonly title?: string;
- /** Default cwd. Default: null (inherit server default). Only used on create. */
- readonly defaultCwd?: string | null;
-}
-
-/** Response of GET/PUT /workspaces/:id — the workspace itself. */
-export interface WorkspaceResponse extends Workspace {}
-
-/** Response of `GET /workspaces` — all workspaces sorted by lastActivityAt desc. */
-export interface WorkspaceListResponse {
- readonly workspaces: readonly WorkspaceEntry[];
-}
-
-/** Body of `PUT /workspaces/:id/title`. */
-export interface SetWorkspaceTitleRequest {
- readonly title: string;
-}
-
-/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */
-export interface SetWorkspaceDefaultCwdRequest {
- readonly defaultCwd: string | null;
-}
-
-/** Response of `DELETE /workspaces/:id`. */
-export interface DeleteWorkspaceResponse {
- readonly workspaceId: string;
- /** Conversations that were closed (status → "closed") by this delete. */
- readonly closedCount: number;
-}
-```
-
----
-
-## 3. Final endpoint list
-
-| Method & Path | Body | Returns | Notes |
-|---|---|---|---|
-| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. |
-| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. |
-| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. |
-| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). |
-| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. |
-| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. |
-| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. |
-| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). |
-
-### Existing endpoints (semantic note, no type change)
-
-- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default).
-- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd.
-
----
-
-## 4. cwd resolution (backend-owned)
-
-```
-effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation
-if (effectiveCwd == null) {
- workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback
- workspace = conversationStore.getWorkspace(workspaceId)
- effectiveCwd = workspace?.defaultCwd ?? null
-}
-if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today
-```
-
-- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit).
-- `GET /conversations/:id/lsp` → effective cwd.
-- Turn start (`runTurn` / `warm`) → effective cwd.
-
----
-
-## 5. `DELETE /workspaces/:id` semantics
-
-1. Close all conversations in that workspace (set `status = "closed"`).
-2. Reassign their `workspaceId` to `"default"` (so no dangling reference).
-3. Delete the workspace entity.
-4. Return `{ workspaceId, closedCount }`.
-5. `DELETE /workspaces/default` → HTTP 409.
-
-Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`).
-
----
-
-## 6. Workspace lifecycle / auto-creation
-
-- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`).
-- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is.
-- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable.
-- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list.
-- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes.
-- **Compaction:** post-compaction conversations inherit the original's `workspaceId`.
-
----
-
-## 7. Answers to FE open questions (Q1–Q8)
-
-| # | Decision |
-|---|---|
-| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. |
-| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). |
-| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. |
-| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). |
-| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. |
-| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. |
-| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. |
-| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. |
-
----
-
-## 8. Gaps resolved (from FE handoff §3)
-
-1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted.
-2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is.
-3. **lastActivityAt on title/default-cwd changes** → no.
-4. **LSP cwd field** → returns effective cwd.
-5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`.
diff --git a/notes/heartbeat-setting-handoff.md b/notes/heartbeat-setting-handoff.md
new file mode 100644
index 0000000..53382aa
--- /dev/null
+++ b/notes/heartbeat-setting-handoff.md
@@ -0,0 +1,130 @@
+# FE handoff — heartbeat `inactiveOnly` (skip while workspace is busy)
+
+Courier this to `../frontend` (cross-repo contract change; `lsp references` does not span
+repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks. The new
+field has a default, so a frontend that never sends it gets the new behavior automatically.
+
+## What shipped (backend)
+
+A new per-workspace heartbeat setting, **`inactiveOnly`**: when on (the default), the
+heartbeat SKIPS a fire whenever the configured workspace has any active agents — i.e. a
+conversation whose persisted status is `"active"` (driving a turn) or `"queued"` (waiting on
+the message queue). The heartbeat stays quiet while the user is actively working and only
+fires when the workspace is idle.
+
+The spawned heartbeat conversation lives in the DEDICATED heartbeat workspace (not the
+configured one), so a heartbeat run never counts as an "active agent" of the configured
+workspace — there is no self-block.
+
+## The setting
+
+```ts
+// @dispatch/transport-contract (HeartbeatConfig)
+interface HeartbeatConfig {
+ enabled: boolean;
+ inactiveOnly: boolean; // ← NEW. default: true
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort | null;
+}
+```
+
+- **Name:** `inactiveOnly`
+- **Type:** `boolean`
+- **Default:** `true` (the heartbeat is quiet-by-default while the workspace is busy)
+- **Semantics:**
+ - `true` → before each scheduled fire, the backend checks the configured workspace for
+ active agents. If any exist, the fire is **silently skipped** (no run is recorded, no
+ `HeartbeatRun` created). The scheduler re-arms and tries again at the next
+ `intervalMinutes` interval.
+ - `false` → the heartbeat fires unconditionally on every interval, regardless of
+ workspace activity (the pre-existing behavior).
+- **What counts as an "active agent":** any conversation in the configured workspace whose
+ persisted `ConversationStatus` is `"active"` or `"queued"`. The orchestrator sets
+ `"active"` when a turn starts and `"idle"` when it settles, so this is the live
+ busy/idle signal.
+- **Self-block safety:** heartbeat-spawned conversations are filed in the `heartbeat`
+ workspace, so an in-flight heartbeat run does NOT keep the configured workspace "busy".
+
+## API endpoints
+
+The setting rides on the existing heartbeat config endpoints — no new routes.
+
+### Read
+
+`GET /workspaces/:id/heartbeat` → `HeartbeatConfig` (200)
+
+The response now includes `inactiveOnly`. For a workspace that never configured a
+heartbeat, the full default config is returned (`enabled: false`, `inactiveOnly: true`,
+`intervalMinutes: 30`, …).
+
+### Update
+
+`PUT /workspaces/:id/heartbeat` with a partial `UpdateHeartbeatRequest` body → `HeartbeatConfig` (200)
+
+```ts
+// @dispatch/transport-contract (UpdateHeartbeatRequest)
+interface UpdateHeartbeatRequest {
+ enabled?: boolean;
+ inactiveOnly?: boolean; // ← NEW. optional; absent = unchanged
+ systemPrompt?: string;
+ taskPrompt?: string;
+ intervalMinutes?: number;
+ model?: string;
+ reasoningEffort?: ReasoningEffort | null;
+}
+```
+
+All fields optional (a partial update); only provided fields are applied. Absent
+`inactiveOnly` leaves it unchanged (distinct from sending `false`).
+
+**Validation:** `inactiveOnly` must be a JSON `boolean` when present. A non-boolean
+(e.g. `"yes"`, `1`) → HTTP 400 `{ error: "Field 'inactiveOnly' must be a boolean" }`.
+The service is NOT called on validation failure.
+
+**Example:**
+
+```http
+PUT /workspaces/proj/heartbeat
+Content-Type: application/json
+
+{ "inactiveOnly": false }
+```
+
+```json
+200 OK
+{
+ "enabled": false,
+ "inactiveOnly": false,
+ "systemPrompt": "",
+ "taskPrompt": "",
+ "intervalMinutes": 30,
+ "model": "",
+ "reasoningEffort": null
+}
+```
+
+## Frontend implementation notes
+
+- Render a **checkbox** (default checked) in the workspace heartbeat settings UI, labelled
+ something like "Only run when the workspace is idle" / "Pause while agents are active".
+- The checkbox reflects and edits `inactiveOnly` on the heartbeat config.
+- On toggle, send `PUT /workspaces/:id/heartbeat` with `{ inactiveOnly: <bool> }` (a partial
+ update — no need to send the rest of the config).
+- A skipped fire produces **no `HeartbeatRun`**, so the runs list (`GET
+ /workspaces/:id/heartbeat/runs`) and the next-run countdown (`GET
+ /workspaces/:id/heartbeat/next-run`) behave exactly as on any other idle interval: the
+ next-run timestamp advances to `now + intervalMinutes`. No new event type is emitted on a
+ skip (it is silent); the backend logs it at `info` level only.
+- Legacy workspaces: a config persisted by an older backend (no `inactiveOnly` field) is
+ read back as `inactiveOnly: true` (the default) — the feature is ON by default for
+ everyone, including pre-existing workspaces.
+
+## Versions / deps
+
+The new field is added to `HeartbeatConfig` / `UpdateHeartbeatRequest` in
+`@dispatch/transport-contract`. It is a purely additive field with a default, so no
+version bump of the pinned `file:` dependency is strictly required to keep working — but
+bump it when convenient to pick up the typed field.
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts
index e6b43cf..14c4ffc 100644
--- a/packages/cli/src/args.test.ts
+++ b/packages/cli/src/args.test.ts
@@ -216,6 +216,49 @@ describe("parseArgs", () => {
expect(result.kind).toBe("error");
if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
});
+
+ it("parses --title flag", () => {
+ const result = parseArgs(["m", "--text", "x", "--title", "My Task"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: undefined,
+ showReasoning: false,
+ open: false,
+ title: "My Task",
+ });
+ });
+
+ it("parses --title with --workspace together", () => {
+ const result = parseArgs(["m", "--text", "x", "--workspace", "ws", "--title", "T"], {
+ defaultServer,
+ });
+ expect(result.kind).toBe("chat");
+ if (result.kind === "chat") {
+ expect(result.workspaceId).toBe("ws");
+ expect(result.title).toBe("T");
+ }
+ });
+
+ it("omits title when --title is not given", () => {
+ const result = parseArgs(["m", "--text", "x"], { defaultServer });
+ expect(result.kind).toBe("chat");
+ if (result.kind === "chat") {
+ expect(result.title).toBeUndefined();
+ expect(result).not.toHaveProperty("title");
+ }
+ });
+
+ it("errors when --title has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--title"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--title requires a value");
+ });
});
describe("list", () => {
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
index 52f1fba..581d4c2 100644
--- a/packages/cli/src/args.ts
+++ b/packages/cli/src/args.ts
@@ -27,6 +27,7 @@ export type ParsedCommand =
readonly showReasoning: boolean;
readonly open: boolean;
readonly workspaceId?: string | undefined;
+ readonly title?: string | undefined;
}
| {
readonly kind: "list";
@@ -307,6 +308,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
let open = false;
let server = opts.defaultServer;
let workspaceId: string | undefined;
+ let title: string | undefined;
for (let i = 1; i < argv.length; i++) {
const arg = argv[i] as string;
@@ -338,6 +340,10 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
case "--open":
open = true;
break;
+ case "--title":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--title requires a value" };
+ title = argv[++i];
+ break;
case "--effort":
if (i + 1 >= argv.length)
return {
@@ -383,5 +389,6 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
showReasoning,
open,
...(workspaceId !== undefined && { workspaceId }),
+ ...(title !== undefined && { title }),
};
}
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index 9fca347..c4fff1e 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -30,7 +30,7 @@ const USAGE = `Usage:
dispatch read <conversationId> [--server <url>]
dispatch open <conversationId> [--server <url>]
dispatch send <conversationId> --text "..." [--file <path>] [--queue] [--open] [--cwd <dir>] [--effort <level>] [--workspace <id>] [--server <url>]
- dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--server <url>] [--show-reasoning] [--open]
+ dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--title <title>] [--server <url>] [--show-reasoning] [--open]
dispatch --help
Effort levels: low, medium, high (default), xhigh, max`;
diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts
index 536d64f..2deb197 100644
--- a/packages/cli/src/message.test.ts
+++ b/packages/cli/src/message.test.ts
@@ -111,6 +111,22 @@ describe("buildChatRequest", () => {
);
expect(req).not.toHaveProperty("workspaceId");
});
+
+ it("includes title when provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", title: "My Task", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.title).toBe("My Task");
+ });
+
+ it("omits title when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("title");
+ });
});
describe("workspace flag → ChatRequest", () => {
@@ -145,3 +161,26 @@ describe("workspace flag → ChatRequest", () => {
expect(req.workspaceId).toBe("shorthand");
});
});
+
+describe("title flag → ChatRequest", () => {
+ const defaultServer = "http://localhost:24203";
+
+ it("--title flag sets title on request", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi", "--title", "My Task"], {
+ defaultServer,
+ });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.title).toBe("My Task");
+ });
+
+ it("--title flag omitted sends no title", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.title).toBeUndefined();
+ expect(req).not.toHaveProperty("title");
+ });
+});
diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts
index ddbec6b..5db9966 100644
--- a/packages/cli/src/message.ts
+++ b/packages/cli/src/message.ts
@@ -39,6 +39,7 @@ interface ChatCmd {
readonly conversationId?: string | undefined;
readonly reasoningEffort?: ReasoningEffort | undefined;
readonly workspaceId?: string | undefined;
+ readonly title?: string | undefined;
readonly showReasoning: boolean;
}
@@ -55,5 +56,6 @@ export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest {
...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }),
...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }),
...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }),
+ ...(cmd.title !== undefined && { title: cmd.title }),
};
}
diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts
index e77d10a..9a6772d 100644
--- a/packages/heartbeat/src/config-store.test.ts
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -77,6 +77,21 @@ describe("applyConfigUpdate (pure)", () => {
const untouched = applyConfigUpdate(withEffort, { enabled: true });
expect(untouched.reasoningEffort).toBe("low");
});
+
+ it("defaults inactiveOnly to true (the heartbeat is quiet by default while the workspace is busy)", () => {
+ expect(DEFAULT_HEARTBEAT_CONFIG.inactiveOnly).toBe(true);
+ });
+
+ it("applies an inactiveOnly update", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ });
+
+ it("leaves inactiveOnly unchanged when absent from the update", () => {
+ const off = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ const untouched = applyConfigUpdate(off, { enabled: true });
+ expect(untouched.inactiveOnly).toBe(false);
+ });
});
describe("createHeartbeatConfigStore", () => {
@@ -106,6 +121,28 @@ describe("createHeartbeatConfigStore", () => {
expect(next.taskPrompt).toBe("c");
});
+ it("round-trips inactiveOnly through persistence", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ const next = await store.update("ws-1", { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ const store2 = createHeartbeatConfigStore(storage);
+ expect((await store2.get("ws-1")).inactiveOnly).toBe(false);
+ });
+
+ it("defaults inactiveOnly to true for legacy configs persisted before the field existed", async () => {
+ const storage = createMemoryStorage();
+ // Simulate a config written by an older build that had no inactiveOnly
+ // field (a pre-inactive-only heartbeat config).
+ await storage.set("config:ws-1", JSON.stringify({ enabled: true, intervalMinutes: 5 }));
+ const store = createHeartbeatConfigStore(storage);
+ const config = await store.get("ws-1");
+ expect(config.enabled).toBe(true);
+ expect(config.intervalMinutes).toBe(5);
+ // The missing field defaults ON (feature on by default) — never `undefined`.
+ expect(config.inactiveOnly).toBe(true);
+ });
+
it("lists persisted workspace ids", async () => {
const store = createHeartbeatConfigStore(createMemoryStorage());
await store.update("ws-b", { enabled: true });
diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts
index f06c9e8..52aca3e 100644
--- a/packages/heartbeat/src/config-store.ts
+++ b/packages/heartbeat/src/config-store.ts
@@ -7,6 +7,7 @@ import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transpor
*/
export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
enabled: false,
+ inactiveOnly: true,
systemPrompt: "",
taskPrompt: "",
intervalMinutes: 30,
@@ -38,6 +39,7 @@ export function applyConfigUpdate(
): HeartbeatConfig {
const next: HeartbeatConfig = {
enabled: update.enabled !== undefined ? update.enabled : current.enabled,
+ inactiveOnly: update.inactiveOnly !== undefined ? update.inactiveOnly : current.inactiveOnly,
systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
intervalMinutes:
@@ -72,6 +74,7 @@ export function createHeartbeatConfigStore(storage: StorageNamespace): Heartbeat
const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
return {
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
+ inactiveOnly: typeof parsed.inactiveOnly === "boolean" ? parsed.inactiveOnly : true,
systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
intervalMinutes:
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
index f245b7e..b044619 100644
--- a/packages/heartbeat/src/extension.ts
+++ b/packages/heartbeat/src/extension.ts
@@ -90,6 +90,19 @@ export const extension: Extension = {
// Lazy (resolved at fire time, mirroring resolvePrompt).
getWorkspaceCwd: async (wsId) =>
(await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
+ // inactiveOnly: the configured workspace is "busy" while any of its
+ // conversations are driving or queued for a turn. The orchestrator
+ // sets persisted status "active" on turn start and "idle" on settle,
+ // so a single store read (filtered to the configured workspaceId) is
+ // the live active-agent check. The heartbeat's own spawned conversation
+ // lives in the DEDICATED heartbeat workspace, so it never self-blocks.
+ hasActiveAgents: async (wsId) => {
+ const active = await conversationStore.listConversations({
+ workspaceId: wsId,
+ status: ["active", "queued"],
+ });
+ return active.length > 0;
+ },
});
// Reconcile stale runs + arm enabled workspaces on boot.
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
index ff1ec10..a6f7ebb 100644
--- a/packages/heartbeat/src/heartbeat.test.ts
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -157,6 +157,7 @@ function createService(opts: {
) => Promise<string>;
readonly getGlobalSystemPrompt?: () => Promise<string>;
readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
}) {
const fake = createFakeTimers();
let id = 0;
@@ -171,6 +172,7 @@ function createService(opts: {
? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
: {}),
...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}),
+ ...(opts.hasActiveAgents !== undefined ? { hasActiveAgents: opts.hasActiveAgents } : {}),
});
return { svc, advance: fake.advance, storage };
}
@@ -181,6 +183,131 @@ describe("createHeartbeatService", () => {
const cfg = await svc.getConfig("ws-1");
expect(cfg.enabled).toBe(false);
expect(cfg.intervalMinutes).toBe(30);
+ // inactiveOnly defaults ON (the heartbeat is quiet by default while the
+ // workspace is busy).
+ expect(cfg.inactiveOnly).toBe(true);
+ });
+
+ describe("inactiveOnly (skip fire while the workspace has active agents)", () => {
+ it("skips the fire (records no run) when inactiveOnly is true and the workspace has active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // interval elapses → fire
+ await flush(); // let the async fire() reach the active-agent check + return
+
+ expect(orch.pending).toHaveLength(0); // no turn started
+ expect(await svc.listRuns("ws-1")).toHaveLength(0); // no run recorded
+ });
+
+ it("still fires when inactiveOnly is true but the workspace has NO active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(false),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("fires unconditionally when inactiveOnly is false, even with active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // Active agents reported, but the setting is OFF → must not block.
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("re-arms and fires on the next interval after a skipped fire (the scheduler keeps ticking)", async () => {
+ const orch = createFakeOrchestrator();
+ let busy = true; // workspace busy on the first fire, free on the next
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(busy),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // first fire — skipped (busy)
+ await flush();
+ expect(orch.pending).toHaveLength(0);
+
+ busy = false; // workspace goes idle
+ advance(60_000); // next interval → fire again
+ await flush();
+ expect(orch.pending).toHaveLength(1); // now it fires
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("does not consult hasActiveAgents when inactiveOnly is false (degrades off cleanly)", async () => {
+ const orch = createFakeOrchestrator();
+ let consulted = false;
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => {
+ consulted = true;
+ return Promise.resolve(true);
+ },
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(consulted).toBe(false); // never asked — setting is off
+ expect(orch.pending).toHaveLength(1);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("checks active agents per configured workspace (only the configured workspace is consulted)", async () => {
+ const orch = createFakeOrchestrator();
+ const busyWorkspaces = new Set<string>(["ws-busy"]);
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: (wsId) => Promise.resolve(busyWorkspaces.has(wsId)),
+ });
+ // ws-free is idle, ws-busy has active agents.
+ await svc.updateConfig("ws-free", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc.updateConfig("ws-busy", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // both fire
+ await flush();
+ // ws-free fired (idle), ws-busy skipped (busy).
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-free"))[0]?.status).toBe("running");
+ expect(await svc.listRuns("ws-busy")).toHaveLength(0);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
});
it("arming an enabled config does not fire immediately (waits for the interval)", () => {
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
index 12e70e3..e04c538 100644
--- a/packages/heartbeat/src/heartbeat.ts
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -116,6 +116,19 @@ export interface HeartbeatServiceDeps {
* (against `conversationStore.getWorkspace`); tests inject a fake.
*/
readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ /**
+ * Whether the configured workspace currently has any ACTIVE agents —
+ * conversations driving (or queued for) a turn. When `config.inactiveOnly`
+ * is `true`, the heartbeat SKIPS a fire while this returns `true` (the
+ * workspace is busy). The extension wires the real check against
+ * `conversationStore.listConversations({ workspaceId, status: ["active",
+ * "queued"] })` (the configured workspace's persisted statuses — the
+ * orchestrator sets `"active"` on turn start, `"idle"` on settle); tests
+ * inject a fake. When omitted, `false` (no active agents → never skip) so
+ * the inactive-only feature degrades off cleanly — the heartbeat fires
+ * unconditionally, matching the pre-inactive-only behavior.
+ */
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
}
interface ActiveRun {
@@ -143,6 +156,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// conversation's workspace). The extension wires the real getter so the
// turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake.
const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null));
+ // Default: no active agents (never skip) — the inactive-only feature degrades
+ // off cleanly. The extension wires the real check (against the conversation
+ // store's persisted statuses); tests inject a fake.
+ const hasActiveAgents = deps.hasActiveAgents ?? (() => Promise.resolve(false));
// runId → active-run tracking (in-memory; the durable record lives in the
// run store). Used to (a) map a stop request to its conversation, and
@@ -160,6 +177,18 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// Race: disabled/disarmed between the timer firing and now.
if (!config.enabled) return;
+ // inactiveOnly: skip this fire when the configured workspace has active
+ // agents (a conversation driving or queued for a turn). The fire is
+ // silently skipped — no run is recorded — and the scheduler re-arms to
+ // try again at the next interval. The spawned heartbeat conversation lives
+ // in the DEDICATED heartbeat workspace, so it never self-blocks (a prior
+ // in-flight heartbeat run is NOT an active agent of the configured
+ // workspace). Disabled (inactiveOnly === false) fires unconditionally.
+ if (config.inactiveOnly && (await hasActiveAgents(workspaceId))) {
+ logger?.info("heartbeat: fire skipped — workspace has active agents", { workspaceId });
+ return;
+ }
+
const conversationId = generateId();
const runId = generateId();
const triggeredAt = new Date(timers.now()).toISOString();
diff --git a/packages/kernel/src/contracts/runtime.ts b/packages/kernel/src/contracts/runtime.ts
index 71d2211..deae126 100644
--- a/packages/kernel/src/contracts/runtime.ts
+++ b/packages/kernel/src/contracts/runtime.ts
@@ -121,13 +121,19 @@ export interface RunTurnInput {
* results. When omitted or returning an empty array, no injection happens
* (the runtime is unchanged).
*
- * Injected (not ambient) so the kernel stays pure: it owns no queue and
- * names no feature — it just calls the callback and appends what it gets.
- * Only invoked when a step PRODUCED tool calls (the tool-result boundary);
- * a step that ends without tool calls does not drain (the caller decides
- * what to do with any pending messages after the turn ends).
+ * May return a Promise (the runtime `await`s it): the shell uses this to
+ * PERSIST the injected messages to the store as part of the same critical
+ * section as the injection, so they are never lost (a fire-and-forget
+ * persist would race with the next step's `onStepComplete` append and
+ * collide on the store's seq counter). A sync return is still supported
+ * (backward-compatible). Injected (not ambient) so the kernel stays pure:
+ * it owns no queue and names no feature — it just calls the callback,
+ * awaits it, and appends what it gets. Only invoked when a step PRODUCED
+ * tool calls (the tool-result boundary); a step that ends without tool
+ * calls does not drain (the caller decides what to do with any pending
+ * messages after the turn ends).
*/
- readonly drainSteering?: () => readonly ChatMessage[];
+ readonly drainSteering?: () => readonly ChatMessage[] | Promise<readonly ChatMessage[]>;
/**
* Optional. Called by the runtime after each step's messages are finalized
@@ -142,6 +148,33 @@ export interface RunTurnInput {
readonly onStepComplete?: (messages: readonly ChatMessage[]) => Promise<void> | void;
/**
+ * Optional. Called by the runtime at each tool-result boundary — after a
+ * step that produced tool calls has been finalized (`onStepComplete` has run
+ * and any `drainSteering` messages have been appended), before the next step
+ * begins — giving the caller a chance to REPLACE the running message
+ * history. The caller returns a new message list which the runtime adopts
+ * as its working history for all subsequent steps, or `undefined`/an empty
+ * array to keep the history unchanged.
+ *
+ * The runtime receives the step's token `usage` (so the caller can check a
+ * threshold against the model's context window) and the current `messages`
+ * (the full prompt the next step would otherwise see). Generic and
+ * feature-agnostic: the runtime calls it and adopts whatever message list it
+ * returns — it names no feature, owns no threshold policy, and performs no
+ * I/O, mirroring `drainSteering`. The shell uses this to compact the history
+ * in-flight when the context window nears capacity, so a long-running turn
+ * (e.g. left overnight) does not run out of context mid-turn: it summarizes
+ * the old history and continues with the summary + recent messages. Only
+ * invoked when a step PRODUCED tool calls (there is a "next step" to compact
+ * before); a step that ends without tool calls ends the turn, so there is no
+ * boundary to compact at. Injected (not ambient) so the kernel stays pure.
+ */
+ readonly onStepBoundary?: (ctx: {
+ readonly stepUsage: Usage;
+ readonly messages: readonly ChatMessage[];
+ }) => Promise<readonly ChatMessage[] | undefined> | (readonly ChatMessage[] | undefined);
+
+ /**
* Optional injected retry strategy for retryable provider errors (e.g. HTTP
* 429 / 5xx "overloaded"). When omitted, a retryable error ends the step
* exactly as before (backward-compatible). When provided, the runtime wraps
diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts
index 8d20975..452e162 100644
--- a/packages/kernel/src/runtime/run-turn.test.ts
+++ b/packages/kernel/src/runtime/run-turn.test.ts
@@ -2853,6 +2853,53 @@ describe("runTurn", () => {
expect(drainCallCount).toBe(2);
});
+ it("async drainSteering (returns a Promise) is awaited — its messages reach the next step (the shell persists before returning)", async () => {
+ // The shell's drainSteering is async so it can persist the injected
+ // messages before returning. The kernel must `await` it (a sync call
+ // would get a Promise, not the array). This test pins that contract.
+ let drainCallCount = 0;
+ const steeringMessage: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: "async steer!" }],
+ };
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ // Async drainSteering — resolves on a microtask, like a real persist.
+ drainSteering: () =>
+ new Promise((resolve) => {
+ drainCallCount++;
+ // Defer the resolve so the kernel MUST await to get the array.
+ queueMicrotask(() => resolve([steeringMessage]));
+ }),
+ });
+
+ expect(drainCallCount).toBe(1);
+ const secondStepMessages = capturedMessages[1] ?? [];
+ // The async-returned steering message was awaited and appended AFTER the
+ // tool result, before the next step — proving the kernel awaited it.
+ expect(secondStepMessages).toHaveLength(4);
+ expect(secondStepMessages[3]).toEqual(steeringMessage);
+ });
+
it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => {
let drainCallCount = 0;
// 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step
@@ -2899,6 +2946,335 @@ describe("runTurn", () => {
});
});
+ // ── onStepBoundary (in-flight history replacement) ──────────────────────
+ //
+ // PURE tests: a capturing provider + a fake `onStepBoundary` that returns a
+ // replacement message list (or void). The kernel must adopt the returned
+ // list as its working history for subsequent steps. ZERO mocks of
+ // `@dispatch/*` — the hook is injected like `drainSteering`.
+
+ describe("onStepBoundary", () => {
+ it("returns a replacement → next step's provider input is the replaced history (old messages dropped)", async () => {
+ const compactedHistory: ChatMessage[] = [
+ { role: "system", chunks: [{ type: "text", text: "SUMMARY" }] },
+ { role: "user", chunks: [{ type: "text", text: "recent" }] },
+ ];
+ let boundaryCallCount = 0;
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 999, outputTokens: 1 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ onStepBoundary: ({ messages }) => {
+ boundaryCallCount++;
+ // Sanity: the kernel passes the full running history at the boundary
+ // (user, assistant tool-call, tool result) — 3 messages.
+ expect(messages.length).toBe(3);
+ return compactedHistory;
+ },
+ });
+
+ expect(boundaryCallCount).toBe(1);
+ expect(capturedMessages).toHaveLength(2);
+ // The second step must see ONLY the replaced history — the original
+ // 3 messages are gone, replaced by [SUMMARY, recent].
+ const secondStepMessages = capturedMessages[1] ?? [];
+ expect(secondStepMessages).toHaveLength(2);
+ expect(secondStepMessages[0]).toEqual(compactedHistory[0]);
+ expect(secondStepMessages[1]).toEqual(compactedHistory[1]);
+ });
+
+ it("returns undefined → history unchanged (byte-identical to omitting the hook)", async () => {
+ let boundaryCallCount = 0;
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ onStepBoundary: () => {
+ boundaryCallCount++;
+ return undefined;
+ },
+ });
+
+ expect(boundaryCallCount).toBe(1);
+ const secondStepMessages = capturedMessages[1] ?? [];
+ // user, assistant(tool-call), tool-result — unchanged.
+ expect(secondStepMessages).toHaveLength(3);
+ expect(secondStepMessages[0]?.role).toBe("user");
+ expect(secondStepMessages[1]?.role).toBe("assistant");
+ expect(secondStepMessages[2]?.role).toBe("tool");
+ });
+
+ it("returns an empty array → treated as no replacement (history unchanged)", async () => {
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ onStepBoundary: () => [],
+ });
+
+ const secondStepMessages = capturedMessages[1] ?? [];
+ // Empty replacement is ignored — history unchanged.
+ expect(secondStepMessages).toHaveLength(3);
+ });
+
+ it("NOT called when a step has no tool calls (text-only turn) — there is no next step", async () => {
+ let boundaryCallCount = 0;
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "hello" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ onStepBoundary: () => {
+ boundaryCallCount++;
+ return undefined;
+ },
+ });
+
+ expect(boundaryCallCount).toBe(0);
+ });
+
+ it("multiple tool-call steps → called once per tool-call step, AFTER drainSteering (steering present in messages)", async () => {
+ let boundaryCallCount = 0;
+ const steeringMessage: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: "steer!" }],
+ };
+ let sawSteeringAtBoundary = false;
+
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ drainSteering: () => [steeringMessage],
+ onStepBoundary: ({ messages }) => {
+ boundaryCallCount++;
+ // drainSteering runs BEFORE onStepBoundary, so the steering message
+ // must already be present in the boundary's `messages`.
+ if (messages.some((m) => m === steeringMessage)) {
+ sawSteeringAtBoundary = true;
+ }
+ return undefined;
+ },
+ });
+
+ // Steps 0 and 1 produced tool calls → boundary once each.
+ // Step 2 (text-only) ends the turn → no boundary. Total = 2.
+ expect(boundaryCallCount).toBe(2);
+ expect(sawSteeringAtBoundary).toBe(true);
+ expect(capturedMessages).toHaveLength(3);
+ });
+
+ it("receives the step's usage as stepUsage", async () => {
+ const seenUsages: Array<{ inputTokens: number; outputTokens: number }> = [];
+ const { provider } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 4242, outputTokens: 17 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ onStepBoundary: ({ stepUsage }) => {
+ seenUsages.push({
+ inputTokens: stepUsage.inputTokens,
+ outputTokens: stepUsage.outputTokens,
+ });
+ return undefined;
+ },
+ });
+
+ expect(seenUsages).toEqual([{ inputTokens: 4242, outputTokens: 17 }]);
+ });
+
+ it("omitted → strict no-op (byte-identical to before)", async () => {
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ // onStepBoundary omitted — must be a strict no-op.
+ });
+
+ const secondStepMessages = capturedMessages[1] ?? [];
+ expect(secondStepMessages).toHaveLength(3);
+ expect(result.messages).toHaveLength(3);
+ });
+
+ it("replacement adopted across multiple subsequent steps (history stays compacted)", async () => {
+ const compactedHistory: ChatMessage[] = [
+ { role: "system", chunks: [{ type: "text", text: "SUMMARY" }] },
+ ];
+ let boundaryCallCount = 0;
+ const { provider, capturedMessages } = createCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const tool = createFakeTool("echo", async () => ({ content: "echoed" }));
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: () => {},
+ // Compact only at the FIRST boundary; subsequent boundaries return void.
+ onStepBoundary: ({ messages }) => {
+ boundaryCallCount++;
+ if (boundaryCallCount === 1) {
+ // Pre-replacement: user + assistant(tool-call) + tool result = 3.
+ expect(messages.length).toBe(3);
+ return compactedHistory;
+ }
+ return undefined;
+ },
+ });
+
+ expect(boundaryCallCount).toBe(2);
+ // After the FIRST boundary compacts to [SUMMARY], step 1's provider call
+ // (captured[1]) sees ONLY [SUMMARY] — the original user message is GONE,
+ // proving the replacement took effect before the next step.
+ const secondStepMessages = capturedMessages[1] ?? [];
+ expect(secondStepMessages).toHaveLength(1);
+ expect(secondStepMessages[0]).toEqual(compactedHistory[0]);
+ // Step 1 then produced tc2 + a tool result (pushed onto messages), so
+ // step 2's provider call (captured[2]) sees [SUMMARY, assistant(tc2),
+ // tool-result] = 3 — the compaction PERSISTED across step 1→2 (the
+ // SUMMARY is still heading the history; the original user message
+ // never came back).
+ const thirdStepMessages = capturedMessages[2] ?? [];
+ expect(thirdStepMessages).toHaveLength(3);
+ expect(thirdStepMessages[0]).toEqual(compactedHistory[0]);
+ expect(thirdStepMessages[1]?.role).toBe("assistant");
+ expect(thirdStepMessages[2]?.role).toBe("tool");
+ });
+ });
+
// ── Retry with backoff ──────────────────────────────────────────────────
//
// PURE tests: a fake `sleep` (records calls, resolves instantly, can abort
diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts
index 3460033..8f68865 100644
--- a/packages/kernel/src/runtime/run-turn.ts
+++ b/packages/kernel/src/runtime/run-turn.ts
@@ -720,11 +720,34 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
// and append them after the tool results, before the next call.
// The kernel owns no queue and names no feature — it just calls
// the callback and appends. Emits nothing (caller emits the
- // `steering` AgentEvent in its own wrapper).
- const steering = input.drainSteering?.() ?? [];
+ // `steering` AgentEvent in its own wrapper). The callback MAY
+ // return a Promise (the shell persists the injected messages
+ // before returning) — `await` handles both sync and async.
+ const steering = (await input.drainSteering?.()) ?? [];
for (const msg of steering) {
messages.push(msg);
}
+
+ // In-flight history replacement boundary: give the caller a chance
+ // to swap the running message history before the next step (e.g. to
+ // compact it when the context window nears capacity). The kernel names
+ // no feature and performs no I/O — it calls the callback and adopts
+ // whatever message list it returns, mirroring `drainSteering`. When the
+ // caller returns a list, the runtime replaces its working history with
+ // it (in place); `undefined`/empty leaves it unchanged. Only reached
+ // when there IS a next step (tool calls were produced).
+ if (input.onStepBoundary !== undefined) {
+ const replacement = await input.onStepBoundary({
+ stepUsage: stepResult.usage,
+ messages,
+ });
+ if (replacement !== undefined && replacement.length > 0) {
+ messages.length = 0;
+ for (const msg of replacement) {
+ messages.push(msg);
+ }
+ }
+ }
}
}
} finally {
diff --git a/packages/mcp/src/client.test.ts b/packages/mcp/src/client.test.ts
index ca8a11b..4542a36 100644
--- a/packages/mcp/src/client.test.ts
+++ b/packages/mcp/src/client.test.ts
@@ -203,4 +203,70 @@ describe("McpClient", () => {
content: [{ type: "text", text: "too late" }],
});
});
+
+ /** A connection whose initialize never responds (simulates a framing-
+ * incompatible server like chrome-devtools-mcp under Content-Length framing):
+ * the pending JSON-RPC request would hang forever without a timeout/abort. */
+ function makeHangingConnection(): Connection {
+ const never = new Promise<unknown>(() => {});
+ return {
+ send: () => never,
+ notify: () => {},
+ onNotification: () => {},
+ close: () => {},
+ pid: 1,
+ };
+ }
+
+ it("initialize raises McpTimeoutError when the server never responds", async () => {
+ const { McpTimeoutError } = await import("./timeout.js");
+ const client = new McpClient({ connection: makeHangingConnection() });
+ await expect(client.initialize(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError);
+ expect(client.getState()).toBe("error");
+ });
+
+ it("initialize is abortable: an aborting signal rejects immediately", async () => {
+ const client = new McpClient({ connection: makeHangingConnection() });
+ const controller = new AbortController();
+ const p = client.initialize(controller.signal, 50_000);
+ controller.abort();
+ await expect(p).rejects.toThrow("Aborted");
+ expect(client.getState()).toBe("error");
+ });
+
+ it("initialize rejects immediately when the signal is already aborted", async () => {
+ const client = new McpClient({ connection: makeHangingConnection() });
+ const controller = new AbortController();
+ controller.abort();
+ await expect(client.initialize(controller.signal, 50_000)).rejects.toThrow("Aborted");
+ });
+
+ it("listTools raises McpTimeoutError when the server never responds", async () => {
+ const { McpTimeoutError } = await import("./timeout.js");
+ // Reach "connected" with a fast (auto-responding) connection, then swap in
+ // a hanging connection for the tools/list call.
+ const fast = makeMockConnection();
+ const client = new McpClient({ connection: fast });
+ await client.initialize();
+
+ const hanging = makeHangingConnection();
+ // Swap the connection so tools/list hangs.
+ (client as unknown as { connection: Connection }).connection = hanging;
+
+ await expect(client.listTools(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError);
+ });
+
+ it("listTools is abortable", async () => {
+ const fast = makeMockConnection();
+ const client = new McpClient({ connection: fast });
+ await client.initialize();
+
+ const hanging = makeHangingConnection();
+ (client as unknown as { connection: Connection }).connection = hanging;
+
+ const controller = new AbortController();
+ const p = client.listTools(controller.signal, 50_000);
+ controller.abort();
+ await expect(p).rejects.toThrow("Aborted");
+ });
});
diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts
index d5d06fc..6a69c00 100644
--- a/packages/mcp/src/client.ts
+++ b/packages/mcp/src/client.ts
@@ -3,8 +3,15 @@
*
* Manages a single MCP server connection: initialize handshake,
* tool discovery, tool invocation, and list_changed notifications.
+ *
+ * Every awaited handshake/list operation is bounded by `withTimeout` (a default
+ * timeout) and an optional `AbortSignal`, so a misbehaving or framing-
+ * incompatible server can never hang the caller (the per-turn tools filter)
+ * indefinitely. `callTool` was already abort-aware; `initialize`/`listTools`
+ * now are too.
*/
+import { MCP_DEFAULT_TIMEOUT_MS, withTimeout } from "./timeout.js";
import type { Connection } from "./transport.js";
import type {
McpCallResult,
@@ -47,14 +54,27 @@ export class McpClient {
this.toolsChangedHandler = handler;
}
- async initialize(): Promise<McpInitializeResult> {
+ /**
+ * Perform the MCP `initialize` handshake. Bounded by `timeoutMs` (default
+ * {@link MCP_DEFAULT_TIMEOUT_MS}) and the optional `signal` (the turn's abort
+ * signal) so a server that never responds cannot hang the caller forever.
+ */
+ async initialize(
+ signal?: AbortSignal,
+ timeoutMs: number = MCP_DEFAULT_TIMEOUT_MS,
+ ): Promise<McpInitializeResult> {
this.state = "connecting";
try {
- const result = (await this.connection.send("initialize", {
- protocolVersion: "2025-11-25",
- capabilities: {},
- clientInfo: { name: "dispatch", version: "0.0.0" },
- })) as McpInitializeResult;
+ const result = (await withTimeout(
+ this.connection.send("initialize", {
+ protocolVersion: "2025-11-25",
+ capabilities: {},
+ clientInfo: { name: "dispatch", version: "0.0.0" },
+ }),
+ "initialize",
+ timeoutMs,
+ signal,
+ )) as McpInitializeResult;
this.capabilities = result.capabilities;
this.connection.notify("notifications/initialized", {});
@@ -73,11 +93,23 @@ export class McpClient {
}
}
- async listTools(): Promise<readonly McpToolInfo[]> {
+ /**
+ * List the server's tools. Bounded by `timeoutMs` (default
+ * {@link MCP_DEFAULT_TIMEOUT_MS}) and the optional `signal`.
+ */
+ async listTools(
+ signal?: AbortSignal,
+ timeoutMs: number = MCP_DEFAULT_TIMEOUT_MS,
+ ): Promise<readonly McpToolInfo[]> {
if (this.state !== "connected") {
throw new Error("Client not connected");
}
- const result = (await this.connection.send("tools/list")) as McpListToolsResult;
+ const result = (await withTimeout(
+ this.connection.send("tools/list"),
+ "tools/list",
+ timeoutMs,
+ signal,
+ )) as McpListToolsResult;
this.tools = result.tools;
return this.tools;
}
diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts
index b937c2b..9e029d2 100644
--- a/packages/mcp/src/extension.test.ts
+++ b/packages/mcp/src/extension.test.ts
@@ -81,6 +81,9 @@ describe("filterMcpTools (pure)", () => {
interface FakeServer {
tools: McpToolInfo[];
failInitialize: boolean;
+ /** When true, the spawn never responds to `initialize` (a hanging /
+ * framing-incompatible server) — used to exercise timeout/abort paths. */
+ hangInitialize: boolean;
emitListChanged: () => void;
}
@@ -113,7 +116,11 @@ function makeFakeSpawn(server: FakeServer): SpawnProcess {
const id = parsed.id ?? 0;
const method = parsed.method;
if (method === "initialize") {
- if (server.failInitialize) {
+ if (server.hangInitialize) {
+ // Never respond — simulates a framing-incompatible server
+ // (e.g. chrome-devtools-mcp under the old Content-Length
+ // framing). The connect must be bounded by timeout/abort.
+ } else if (server.failInitialize) {
emit(
encode(
JSON.stringify({
@@ -246,7 +253,12 @@ const assembly = (tools: ToolContract[], cwd = "/proj"): ToolAssembly => ({
const flush = () => new Promise((r) => setTimeout(r, 0));
function makeServer(initialTools: McpToolInfo[]): FakeServer {
- return { tools: [...initialTools], failInitialize: false, emitListChanged: () => {} };
+ return {
+ tools: [...initialTools],
+ failInitialize: false,
+ hangInitialize: false,
+ emitListChanged: () => {},
+ };
}
function makeExt(server: FakeServer, configJson: string): Extension {
@@ -360,4 +372,95 @@ describe("mcp extension lifecycle", () => {
const after = await service.status("/proj");
expect(after[0].state).toBe("disconnected");
});
+
+ // -------------------------------------------------------------------------
+ // Bug 2 + Bug 3: a misbehaving/hanging server must not hang a turn, and the
+ // turn's AbortSignal (assembly.signal) must interrupt a stuck connect.
+ // -------------------------------------------------------------------------
+
+ it("degrades gracefully (no MCP tools) when the turn's signal is already aborted", async () => {
+ const server = makeServer([tool("create_object")]);
+ const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
+ const { host, getFilter } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ const controller = new AbortController();
+ controller.abort();
+
+ const base = assembly([]);
+ // The filter must NOT hang on the (never-needed) connect: an aborted turn
+ // signal propagates to initialize, which rejects immediately.
+ const result = await filter({
+ tools: base.tools,
+ cwd: base.cwd,
+ conversationId: base.conversationId,
+ signal: controller.signal,
+ });
+
+ expect(result.tools).toEqual([]);
+ ext.deactivate?.();
+ });
+
+ it("the turn's signal aborts a hanging server connect (POST /stop interrupts)", async () => {
+ // hangInitialize: the spawn never responds to initialize (a framing-
+ // incompatible / misbehaving server). Without abort propagation this
+ // would hang the filter until MCP_CONNECT_TIMEOUT_MS; with propagation
+ // the abort breaks it immediately.
+ const server = makeServer([tool("create_object")]);
+ server.hangInitialize = true;
+ const ext = makeExt(server, dispatchConfig({ chrome: { command: "fake" } }));
+ const { host, getFilter } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ const controller = new AbortController();
+ const base = assembly([]);
+ const resultPromise = filter({
+ tools: base.tools,
+ cwd: base.cwd,
+ conversationId: base.conversationId,
+ signal: controller.signal,
+ });
+
+ // Let the filter progress into the hanging initialize (withTimeout has
+ // its abort listener armed), THEN abort — a true mid-flight cancel
+ // simulating POST /conversations/:id/stop. Without signal propagation
+ // this would hang ~30s (the connect backstop) and time out the test.
+ await flush();
+ controller.abort();
+
+ const result = await resultPromise;
+ // Degraded: no MCP tools surfaced, and the filter resolved (did not hang).
+ expect(result.tools).toEqual([]);
+ ext.deactivate?.();
+ });
+
+ it("non-MCP tools pass through unchanged when an MCP connect fails", async () => {
+ // failInitialize: the server rejects initialize (a fast failure, not a
+ // hang) so the connect degrades promptly without waiting on a backstop.
+ const server = makeServer([tool("create_object")]);
+ server.failInitialize = true;
+ const ext = makeExt(server, dispatchConfig({ chrome: { command: "fake" } }));
+ const { host, getFilter } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ const stubNonMcp: ToolContract = {
+ name: "run_shell",
+ description: "kept",
+ parameters: { type: "object" },
+ execute: async () => ({ content: "" }),
+ };
+
+ const result = await filter({
+ tools: [stubNonMcp],
+ cwd: "/proj",
+ conversationId: "c",
+ });
+
+ // Non-MCP tool survives; the failed MCP server contributed no tools.
+ expect(result.tools.map((t) => t.name)).toEqual(["run_shell"]);
+ ext.deactivate?.();
+ });
});
diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts
index d12d420..bb951ee 100644
--- a/packages/mcp/src/extension.ts
+++ b/packages/mcp/src/extension.ts
@@ -18,6 +18,7 @@ import { resolveServers } from "./config.js";
import type { Logger } from "./manager.js";
import { McpManager } from "./manager.js";
import { adaptTool, namespace } from "./registry.js";
+import { MCP_CONNECT_TIMEOUT_MS } from "./timeout.js";
import type { SpawnedProcess, SpawnProcess } from "./transport.js";
import { createStdioTransport } from "./transport.js";
import type { McpServerStatus, McpService, ResolvedMcpServer } from "./types.js";
@@ -111,13 +112,19 @@ export function makeMcpExtension(deps: McpExtensionDeps): Extension {
}
}
- async function connectAndRegister(server: ResolvedMcpServer, cwd: string): Promise<void> {
- const client = await manager.ensureConnected(server, cwd);
+ async function connectAndRegister(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
+ const client = await manager.ensureConnected(server, cwd, signal);
registerToolsFromClient(server.id, client);
// Wire list_changed → re-list → re-register. onToolsChanged replaces
// the handler; ensureConnected returns the same cached client so this
- // is idempotent across turns.
+ // is idempotent across turns. The async re-list runs LATER (not during
+ // this filter), so it is NOT bound to the filter's signal (already
+ // done) — it relies on listTools()'s own default timeout instead.
client.onToolsChanged(async () => {
try {
await client.listTools();
@@ -133,18 +140,44 @@ export function makeMcpExtension(deps: McpExtensionDeps): Extension {
// Resolve config + ensure servers connected, then drop tools whose
// server is not connected. Lazy-spawn happens here (first turn).
+ //
+ // The whole connect phase is wrapped in a per-filter AbortController
+ // that fires on EITHER (a) the turn's signal (`assembly.signal`, so
+ // POST /conversations/:id/stop interrupts a stuck connect immediately)
+ // OR (b) a timeout (`MCP_CONNECT_TIMEOUT_MS`, so a misbehaving /
+ // framing-incompatible server cannot hang the turn forever). On abort
+ // we degrade gracefully: skip MCP tools for this turn rather than block.
host.addFilter(toolsFilter, async (assembly: ToolAssembly): Promise<ToolAssembly> => {
const cwd = assembly.cwd ?? deps.getCwd();
const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json"));
const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json"));
const { servers } = resolveServers({ dispatchMcpJson, opencodeJson });
- for (const server of servers) {
- try {
- await connectAndRegister(server, cwd);
- } catch {
- // Connection failure — the manager tracks broken state.
+ const controller = new AbortController();
+ const parentSignal = assembly.signal;
+ const onParentAbort = (): void => controller.abort();
+ if (parentSignal !== undefined) {
+ if (parentSignal.aborted) {
+ controller.abort();
+ } else {
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
+ }
+ }
+ const timer = setTimeout(() => controller.abort(), MCP_CONNECT_TIMEOUT_MS);
+
+ try {
+ for (const server of servers) {
+ try {
+ await connectAndRegister(server, cwd, controller.signal);
+ } catch {
+ // Connection failure / timeout / aborted — the manager tracks
+ // broken state; we keep going (or abort cascades) below.
+ }
+ if (controller.signal.aborted) break;
}
+ } finally {
+ clearTimeout(timer);
+ parentSignal?.removeEventListener("abort", onParentAbort);
}
const statuses = manager.status(servers);
diff --git a/packages/mcp/src/framing.test.ts b/packages/mcp/src/framing.test.ts
index 3b207a3..9832c74 100644
--- a/packages/mcp/src/framing.test.ts
+++ b/packages/mcp/src/framing.test.ts
@@ -1,84 +1,74 @@
import { describe, expect, it } from "vitest";
import { encode, FrameDecoder } from "./framing.js";
+/** Build a legacy Content-Length frame (for exercising the decoder's CL path). */
+function contentLengthFrame(body: string): Uint8Array {
+ const bodyBytes = new TextEncoder().encode(body);
+ return new TextEncoder().encode(`Content-Length: ${bodyBytes.length}\r\n\r\n${body}`);
+}
+
describe("encode", () => {
- it("produces correct Content-Length header", () => {
+ it("produces newline-delimited JSON (current MCP spec framing)", () => {
const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
const encoded = encode(msg);
const text = new TextDecoder().decode(encoded);
- expect(text).toBe(`Content-Length: ${new TextEncoder().encode(msg).length}\r\n\r\n${msg}`);
+ expect(text).toBe(`${msg}\n`);
});
- it("handles empty message", () => {
+ it("appends a trailing newline to an empty message", () => {
const encoded = encode("");
const text = new TextDecoder().decode(encoded);
- expect(text).toBe("Content-Length: 0\r\n\r\n");
+ expect(text).toBe("\n");
});
});
-describe("FrameDecoder", () => {
- it("reassembles a complete message from one chunk", () => {
+describe("FrameDecoder — newline-delimited JSON", () => {
+ it("decodes a single newline-delimited message", () => {
+ const msg = '{"jsonrpc":"2.0","id":1}';
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(encode(msg))).toEqual([msg]);
+ });
+
+ it("decodes a CRLF-terminated message (trailing \\r tolerated)", () => {
const msg = '{"jsonrpc":"2.0","id":1}';
- const encoded = encode(msg);
const decoder = new FrameDecoder();
- const messages = decoder.decode(encoded);
- expect(messages).toEqual([msg]);
+ const framed = new TextEncoder().encode(`${msg}\r\n`);
+ expect(decoder.decode(framed)).toEqual([msg]);
});
- it("handles split across chunks", () => {
+ it("handles a split across chunks", () => {
const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
const encoded = encode(msg);
const mid = Math.floor(encoded.length / 2);
- const chunk1 = encoded.slice(0, mid);
- const chunk2 = encoded.slice(mid);
const decoder = new FrameDecoder();
- const result1 = decoder.decode(chunk1);
- expect(result1).toEqual([]);
-
- const result2 = decoder.decode(chunk2);
- expect(result2).toEqual([msg]);
+ expect(decoder.decode(encoded.slice(0, mid))).toEqual([]);
+ expect(decoder.decode(encoded.slice(mid))).toEqual([msg]);
});
it("handles two messages in one chunk", () => {
const msg1 = '{"jsonrpc":"2.0","id":1}';
const msg2 = '{"jsonrpc":"2.0","id":2}';
- const encoded1 = encode(msg1);
- const encoded2 = encode(msg2);
- const combined = new Uint8Array(encoded1.length + encoded2.length);
- combined.set(encoded1);
- combined.set(encoded2, encoded1.length);
+ const combined = new Uint8Array(encode(msg1).length + encode(msg2).length);
+ combined.set(encode(msg1));
+ combined.set(encode(msg2), encode(msg1).length);
const decoder = new FrameDecoder();
- const messages = decoder.decode(combined);
- expect(messages).toEqual([msg1, msg2]);
+ expect(decoder.decode(combined)).toEqual([msg1, msg2]);
});
- it("rejects negative Content-Length by skipping header", () => {
- const header = "Content-Length: -5\r\n\r\n";
- const encoded = new TextEncoder().encode(`${header}extra`);
- const decoder = new FrameDecoder();
- const messages = decoder.decode(encoded);
- // Negative length does not match the digit capture, so the header is skipped.
- expect(messages).toEqual([]);
- });
-
- it("rejects zero Content-Length", () => {
- const encoded = encode("");
+ it("skips blank lines between messages", () => {
+ const msg = '{"jsonrpc":"2.0","id":1}';
+ const framed = new TextEncoder().encode(`\n\n${msg}\n\n`);
const decoder = new FrameDecoder();
- const messages = decoder.decode(encoded);
- expect(messages).toEqual([""]);
+ expect(decoder.decode(framed)).toEqual([msg]);
});
- it("reassembles multi-byte UTF-8 content (byte-length, not char-length)", () => {
- // "héllo" — é is two UTF-8 bytes; Content-Length counts bytes.
+ it("reassembles multi-byte UTF-8 content (byte-aware, not char-aware)", () => {
const msg = '{"text":"héllo 🚀"}';
- const encoded = encode(msg);
expect(new TextEncoder().encode(msg).length).toBeGreaterThan(msg.length);
-
const decoder = new FrameDecoder();
- const messages = decoder.decode(encoded);
- expect(messages).toEqual([msg]);
+ expect(decoder.decode(encode(msg))).toEqual([msg]);
});
it("reassembles multi-byte content split across a chunk boundary", () => {
@@ -89,4 +79,85 @@ describe("FrameDecoder", () => {
expect(decoder.decode(encoded.slice(0, mid))).toEqual([]);
expect(decoder.decode(encoded.slice(mid))).toEqual([msg]);
});
+
+ it("does not split a JSON string containing an escaped \\n (no raw newline)", () => {
+ // JSON escapes newlines inside strings as the two chars `\` + `n`; a raw
+ // 0x0a only ever appears as a message separator. So a JSON body carrying
+ // an embedded newline literal survives intact.
+ const msg = '{"text":"line1\\nline2"}';
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(encode(msg))).toEqual([msg]);
+ });
+});
+
+describe("FrameDecoder — legacy Content-Length framing (auto-detected)", () => {
+ it("decodes a Content-Length-framed message", () => {
+ const msg = '{"jsonrpc":"2.0","id":1}';
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(contentLengthFrame(msg))).toEqual([msg]);
+ });
+
+ it("reassembles a Content-Length frame split across chunks", () => {
+ const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
+ const encoded = contentLengthFrame(msg);
+ const mid = Math.floor(encoded.length / 2);
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(encoded.slice(0, mid))).toEqual([]);
+ expect(decoder.decode(encoded.slice(mid))).toEqual([msg]);
+ });
+
+ it("decodes two Content-Length frames in one chunk", () => {
+ const msg1 = '{"jsonrpc":"2.0","id":1}';
+ const msg2 = '{"jsonrpc":"2.0","id":2}';
+ const combined = new Uint8Array(
+ contentLengthFrame(msg1).length + contentLengthFrame(msg2).length,
+ );
+ combined.set(contentLengthFrame(msg1));
+ combined.set(contentLengthFrame(msg2), contentLengthFrame(msg1).length);
+
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(combined)).toEqual([msg1, msg2]);
+ });
+
+ it("rejects negative Content-Length by skipping header", () => {
+ const encoded = new TextEncoder().encode("Content-Length: -5\r\n\r\nextra");
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(encoded)).toEqual([]);
+ });
+
+ it("accepts zero Content-Length as an empty message", () => {
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(contentLengthFrame(""))).toEqual([""]);
+ });
+
+ it("reassembles multi-byte UTF-8 via Content-Length (byte count)", () => {
+ const msg = '{"text":"héllo 🚀"}';
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(contentLengthFrame(msg))).toEqual([msg]);
+ });
+
+ it("does not mis-read a partial 'Content-Length' prefix as newline-delimited", () => {
+ // A buffer that is a partial prefix of "Content-Length:" must WAIT for more
+ // bytes rather than being split on a (nonexistent) newline.
+ const decoder = new FrameDecoder();
+ const partial = new TextEncoder().encode("Content-Len");
+ expect(decoder.decode(partial)).toEqual([]);
+ const rest = new TextEncoder().encode("gth: 3\r\n\r\nabc");
+ expect(decoder.decode(rest)).toEqual(["abc"]);
+ });
+});
+
+describe("FrameDecoder — mixed framings", () => {
+ it("decodes a Content-Length frame followed by a newline-delimited message", () => {
+ const clMsg = '{"jsonrpc":"2.0","id":1}';
+ const nlMsg = '{"jsonrpc":"2.0","id":2}';
+ const cl = contentLengthFrame(clMsg);
+ const nl = encode(nlMsg);
+ const combined = new Uint8Array(cl.length + nl.length);
+ combined.set(cl);
+ combined.set(nl, cl.length);
+
+ const decoder = new FrameDecoder();
+ expect(decoder.decode(combined)).toEqual([clMsg, nlMsg]);
+ });
});
diff --git a/packages/mcp/src/framing.ts b/packages/mcp/src/framing.ts
index 5f818e2..d30b871 100644
--- a/packages/mcp/src/framing.ts
+++ b/packages/mcp/src/framing.ts
@@ -1,32 +1,40 @@
/**
- * Content-Length framing for MCP stdio transport.
+ * MCP stdio framing.
*
- * Each JSON-RPC message is framed as:
- * Content-Length: <byte-length>\r\n\r\n<JSON bytes>
+ * The MCP spec (revision 2025-03-26 and later, including 2025-11-25) frames
+ * JSON-RPC messages over stdio as **newline-delimited JSON**: each message is
+ * a single JSON document followed by a `\n` (or `\r\n`). This replaced the
+ * older LSP-style `Content-Length: N\r\n\r\n<JSON>` framing that the protocol
+ * inherited originally. Modern servers (e.g. chrome-devtools-mcp) speak ONLY
+ * newline-delimited JSON — they emit `<JSON>\n` on stdout and do not respond to
+ * `Content-Length`-framed input at all.
*
- * Same framing as LSP — the MCP spec inherited this from the LSP base protocol.
+ * Outgoing messages are therefore encoded as newline-delimited JSON (the
+ * current spec default), so we can talk to modern servers. The decoder
+ * **auto-detects** the incoming framing per message — it accepts BOTH
+ * newline-delimited JSON and legacy `Content-Length` frames — so we still
+ * interoperate with servers that respond with the old framing.
*
* PURE: no I/O. Operates on bytes (Uint8Array) so multi-byte UTF-8 content is
* handled correctly — `Content-Length` is a *byte* count, not a character count.
*/
+const HEADER_PREFIX = "Content-Length:";
const HEADER_SEP = "\r\n\r\n";
const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i;
-const SEP_BYTES = new TextEncoder().encode(HEADER_SEP);
+const encoder = new TextEncoder();
+const SEP_BYTES = encoder.encode(HEADER_SEP);
+
+const CR = 0x0d;
+const LF = 0x0a;
/**
- * Encode a JSON string into a single Content-Length-framed message.
- * Returns the full frame (header + blank line + body) as bytes.
+ * Encode a JSON string as a single newline-delimited frame (the current MCP
+ * spec stdio framing): `<JSON>\n`. This is what we send to MCP servers.
*/
export function encode(msg: string): Uint8Array {
- const body = new TextEncoder().encode(msg);
- const header = `Content-Length: ${body.length}\r\n\r\n`;
- const frame = new TextEncoder().encode(header);
- const result = new Uint8Array(frame.length + body.length);
- result.set(frame);
- result.set(body, frame.length);
- return result;
+ return encoder.encode(`${msg}\n`);
}
/** Find the first occurrence of `needle` in `haystack` at or after `from`. -1 if absent. */
@@ -46,9 +54,48 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n
return -1;
}
+/** Find the first occurrence of a single byte at or after `from`. -1 if absent. */
+function indexOfByte(haystack: Uint8Array, needle: number, from: number): number {
+ for (let i = from; i < haystack.length; i++) {
+ if (haystack[i] === needle) return i;
+ }
+ return -1;
+}
+
+/** Lowercase an ASCII byte (A-Z → a-z); leave everything else unchanged. */
+function toLowerByte(b: number): number {
+ return b >= 0x41 && b <= 0x5a ? b + 0x20 : b;
+}
+
+function isLineTerminator(b: number | undefined): boolean {
+ return b === CR || b === LF;
+}
+
+/**
+ * How does `buf` relate to the `Content-Length:` header prefix (case-insensitive)?
+ * - `HEADER_PREFIX.length` → `buf` starts with the full `Content-Length:` prefix.
+ * - a positive number `< HEADER_PREFIX.length` → `buf` is a (possibly partial)
+ * prefix of `Content-Length:` — ambiguous, the caller must wait for more bytes.
+ * - `-1` → `buf` definitively does NOT start with `Content-Length:` (not CL framing).
+ */
+function contentLengthPrefixLength(buf: Uint8Array): number {
+ const n = Math.min(buf.length, HEADER_PREFIX.length);
+ for (let i = 0; i < n; i++) {
+ const a = buf[i];
+ if (a === undefined) return -1; // unreachable: i < n <= buf.length
+ if (toLowerByte(a) !== toLowerByte(HEADER_PREFIX.charCodeAt(i))) return -1;
+ }
+ return n;
+}
+
/**
* Feed raw bytes into the decoder. Returns all complete JSON messages that can
* be extracted from the accumulated buffer. Buffers partial frames across calls.
+ *
+ * Auto-detects framing per message: a `Content-Length:`-prefixed buffer is parsed
+ * as a Content-Length frame (legacy/LSP-style); anything else is parsed as
+ * newline-delimited JSON (current MCP spec). Both framings may be mixed in a
+ * single stream.
*/
export class FrameDecoder {
private buf: Uint8Array = new Uint8Array(0);
@@ -56,45 +103,90 @@ export class FrameDecoder {
decode(chunk: Uint8Array): string[] {
// Append the incoming chunk to the internal byte buffer.
- const next = new Uint8Array(this.buf.length + chunk.length);
- next.set(this.buf);
- next.set(chunk, this.buf.length);
- this.buf = next;
+ if (chunk.length > 0) {
+ const next = new Uint8Array(this.buf.length + chunk.length);
+ next.set(this.buf);
+ next.set(chunk, this.buf.length);
+ this.buf = next;
+ }
const messages: string[] = [];
while (true) {
- const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0);
- if (sepIdx === -1) break;
-
- // The header block is everything before the separator; parse
- // Content-Length from it (ASCII, so decoding the slice is safe).
- const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx));
- const match = CONTENT_LENGTH_RE.exec(headerText);
- const bodyStart = sepIdx + SEP_BYTES.length;
-
- if (!match?.[1]) {
- // No usable Content-Length — drop this header and continue scanning.
- this.buf = this.buf.subarray(bodyStart);
- continue;
- }
+ // 1. Skip leading CR/LF whitespace between frames.
+ let i = 0;
+ while (i < this.buf.length && isLineTerminator(this.buf[i])) i++;
+ if (i > 0) this.buf = this.buf.subarray(i);
+ if (this.buf.length === 0) break;
- const length = Number.parseInt(match[1], 10);
- if (length < 0) {
- this.buf = this.buf.subarray(bodyStart);
+ // 2. Detect framing.
+ const prefix = contentLengthPrefixLength(this.buf);
+ if (prefix >= 0 && prefix < HEADER_PREFIX.length) {
+ // Buffer is a (possibly partial) prefix of "Content-Length:" — ambiguous;
+ // wait for more bytes before deciding this is (or isn't) a CL frame.
+ break;
+ }
+ if (prefix === HEADER_PREFIX.length) {
+ // Content-Length framing.
+ const result = this.tryParseContentLength();
+ if (result === "incomplete") break;
+ if (result !== "skip") messages.push(result);
continue;
}
- if (this.buf.length - bodyStart < length) {
- // Body not fully received yet; wait for more bytes.
- break;
- }
+ // 3. Newline-delimited JSON: one message per line, terminated by `\n`
+ // (a trailing `\r` before the `\n` is tolerated). A raw newline byte
+ // can only ever appear as a message separator — JSON escapes newlines
+ // inside strings as the two characters `\n`, never a literal 0x0a — so
+ // splitting on `\n` bytes is safe for valid JSON.
+ const nl = indexOfByte(this.buf, LF, 0);
+ if (nl === -1) break; // incomplete line — wait for more bytes
- // Decode exactly `length` body bytes (preserves multi-byte UTF-8).
- messages.push(this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length)));
- this.buf = this.buf.subarray(bodyStart + length);
+ let end = nl;
+ if (end > 0 && this.buf[end - 1] === CR) end--;
+ const text = this.decoder.decode(this.buf.subarray(0, end));
+ this.buf = this.buf.subarray(nl + 1);
+ if (text.length > 0) messages.push(text);
}
return messages;
}
+
+ /**
+ * Parse one Content-Length frame from the front of `this.buf`. Returns:
+ * - the decoded body string (possibly `""` for a zero-length body),
+ * - `"incomplete"` if the header or body hasn't fully arrived (caller waits),
+ * - `"skip"` if the header was consumed but carried no usable Content-Length
+ * (caller continues scanning without emitting a message).
+ */
+ private tryParseContentLength(): string | "incomplete" | "skip" {
+ const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0);
+ if (sepIdx === -1) return "incomplete"; // header not fully received yet
+
+ const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx));
+ const match = CONTENT_LENGTH_RE.exec(headerText);
+ const bodyStart = sepIdx + SEP_BYTES.length;
+
+ if (!match?.[1]) {
+ // No usable Content-Length — drop this header and continue scanning.
+ this.buf = this.buf.subarray(bodyStart);
+ return "skip";
+ }
+
+ const length = Number.parseInt(match[1], 10);
+ if (length < 0) {
+ this.buf = this.buf.subarray(bodyStart);
+ return "skip";
+ }
+
+ if (this.buf.length - bodyStart < length) {
+ // Body not fully received yet; wait for more bytes.
+ return "incomplete";
+ }
+
+ // Decode exactly `length` body bytes (preserves multi-byte UTF-8).
+ const body = this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length));
+ this.buf = this.buf.subarray(bodyStart + length);
+ return body;
+ }
}
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index 6902244..6d186d7 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -11,6 +11,12 @@ export { encode, FrameDecoder } from "./framing.js";
export { type Logger, McpManager, type McpManagerDeps } from "./manager.js";
export { adaptTool, flattenContent, namespace } from "./registry.js";
export {
+ MCP_CONNECT_TIMEOUT_MS,
+ MCP_DEFAULT_TIMEOUT_MS,
+ McpTimeoutError,
+ withTimeout,
+} from "./timeout.js";
+export {
type Connection,
createStdioTransport,
type SpawnedProcess,
diff --git a/packages/mcp/src/manager.test.ts b/packages/mcp/src/manager.test.ts
index 7cc53b8..d6179b7 100644
--- a/packages/mcp/src/manager.test.ts
+++ b/packages/mcp/src/manager.test.ts
@@ -213,4 +213,29 @@ describe("McpManager", () => {
expect(manager.getClient("nonexistent")).toBeUndefined();
});
+
+ it("forwards the abort signal: a hanging initialize is interrupted", async () => {
+ // A connection whose initialize never resolves (a misbehaving /
+ // framing-incompatible server). Without a signal this hangs forever.
+ const hangingConn: Connection = {
+ send: () => new Promise(() => {}),
+ notify: () => {},
+ onNotification: () => {},
+ close: () => {},
+ pid: 300,
+ };
+ const manager = makeManager(() => {
+ return { connection: hangingConn, promise: Promise.resolve() };
+ });
+
+ const controller = new AbortController();
+ const connectPromise = manager.ensureConnected(testServer, "/tmp", controller.signal);
+
+ // Abort mid-connect — the signal must reach initialize() and break it.
+ controller.abort();
+
+ await expect(connectPromise).rejects.toThrow();
+ // The server is recorded as broken (not silently wedged).
+ expect(manager.status([testServer])[0].state).toBe("error");
+ });
});
diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts
index 08ad389..1d719c6 100644
--- a/packages/mcp/src/manager.ts
+++ b/packages/mcp/src/manager.ts
@@ -104,7 +104,19 @@ export class McpManager {
return results;
}
- async ensureConnected(server: ResolvedMcpServer, cwd: string): Promise<McpClient> {
+ /**
+ * Ensure a client for `server` is connected, lazily spawning + handshaking on
+ * first access. The optional `signal` (the turn's abort signal) is forwarded
+ * into the `initialize`/`listTools` handshake so `POST /conversations/:id/stop`
+ * can interrupt a stuck connect; the operations are independently bounded by
+ * their own default timeout, so a misbehaving server cannot hang a turn even
+ * when no signal is supplied.
+ */
+ async ensureConnected(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<McpClient> {
const existing = this.clients.get(server.id);
if (existing && existing.client.getState() === "connected") {
return existing.client;
@@ -119,7 +131,7 @@ export class McpManager {
this.broken.delete(server.id);
}
- await this.spawnClient(server, cwd);
+ await this.spawnClient(server, cwd, signal);
const entry = this.clients.get(server.id);
if (!entry) {
throw new Error(`Failed to spawn MCP client for ${server.id}`);
@@ -131,11 +143,15 @@ export class McpManager {
return entry.client;
}
- private async spawnClient(server: ResolvedMcpServer, cwd: string): Promise<void> {
+ private async spawnClient(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
const existingSpawn = this.spawning.get(server.id);
if (existingSpawn) return existingSpawn;
- const spawnPromise = this.doSpawn(server, cwd);
+ const spawnPromise = this.doSpawn(server, cwd, signal);
this.spawning.set(server.id, spawnPromise);
try {
@@ -145,7 +161,11 @@ export class McpManager {
}
}
- private async doSpawn(server: ResolvedMcpServer, cwd: string): Promise<void> {
+ private async doSpawn(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
const { connection, promise } = this.connectionFactory(server, cwd);
const client = new McpClient({ connection });
@@ -153,7 +173,7 @@ export class McpManager {
const entry: ClientEntry = {
client,
server,
- promise: this.initClient(client, server, promise),
+ promise: this.initClient(client, server, promise, signal),
};
this.clients.set(server.id, entry);
@@ -172,10 +192,11 @@ export class McpManager {
client: McpClient,
server: ResolvedMcpServer,
_transportPromise: Promise<void>,
+ signal?: AbortSignal,
): Promise<void> {
try {
- await client.initialize();
- await client.listTools();
+ await client.initialize(signal);
+ await client.listTools(signal);
this.deps.logger?.info("MCP server connected", {
serverId: server.id,
toolCount: String(client.getTools().length),
diff --git a/packages/mcp/src/rpc.test.ts b/packages/mcp/src/rpc.test.ts
index dc295af..34b6af4 100644
--- a/packages/mcp/src/rpc.test.ts
+++ b/packages/mcp/src/rpc.test.ts
@@ -87,11 +87,10 @@ describe("JsonRpcClient", () => {
client.request("c");
expect(written.length).toBe(3);
- // Extract JSON body from Content-Length framed messages
+ // Outgoing messages are newline-delimited JSON (current MCP spec framing).
const parse = (bytes: Uint8Array): { id: number } => {
const text = new TextDecoder().decode(bytes);
- const bodyStart = text.indexOf("\r\n\r\n") + 4;
- return JSON.parse(text.slice(bodyStart)) as { id: number };
+ return JSON.parse(text.replace(/\r?\n$/, "")) as { id: number };
};
const msg1 = parse(written[0]);
const msg2 = parse(written[1]);
diff --git a/packages/mcp/src/timeout.test.ts b/packages/mcp/src/timeout.test.ts
new file mode 100644
index 0000000..38961d0
--- /dev/null
+++ b/packages/mcp/src/timeout.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+import { McpTimeoutError, withTimeout } from "./timeout.js";
+
+/**
+ * `withTimeout` uses a single `setTimeout` (the only edge). Tests drive it
+ * deterministically via `AbortController` for the abort path, and use real
+ * (tiny) timers for the timeout path — vitest's async scheduler is fast enough
+ * at single-digit ms that this is stable.
+ */
+
+describe("withTimeout", () => {
+ it("resolves with the underlying value when the promise settles first", async () => {
+ const result = await withTimeout(Promise.resolve("ok"), "initialize", 1000);
+ expect(result).toBe("ok");
+ });
+
+ it("rejects with the underlying error when the promise rejects first", async () => {
+ await expect(
+ withTimeout(Promise.reject(new Error("boom")), "initialize", 1000),
+ ).rejects.toThrow("boom");
+ });
+
+ it("rejects with McpTimeoutError when the timeout fires first", async () => {
+ const never = new Promise<string>(() => {}); // never settles
+ const p = withTimeout(never, "initialize", 20);
+ await expect(p).rejects.toBeInstanceOf(McpTimeoutError);
+ await expect(p).rejects.toMatchObject({ method: "initialize", timeoutMs: 20 });
+ });
+
+ it("cleans up the timer after the promise settles (no leak)", async () => {
+ // If the timer were not cleared, vitest would report an unhandled timer;
+ // resolving quickly should leave nothing pending.
+ const result = await withTimeout(Promise.resolve(1), "tools/list", 50_000);
+ expect(result).toBe(1);
+ });
+
+ it("rejects with Error('Aborted') when the signal is already aborted", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ withTimeout(new Promise(() => {}), "initialize", 50_000, controller.signal),
+ ).rejects.toThrow("Aborted");
+ });
+
+ it("rejects with Error('Aborted') when the signal aborts mid-flight", async () => {
+ const controller = new AbortController();
+ const never = new Promise<string>(() => {});
+ const p = withTimeout(never, "initialize", 50_000, controller.signal);
+ controller.abort();
+ await expect(p).rejects.toThrow("Aborted");
+ });
+
+ it("abort beats timeout (immediate cancellation)", async () => {
+ const controller = new AbortController();
+ const never = new Promise<string>(() => {});
+ const p = withTimeout(never, "initialize", 50_000, controller.signal);
+ controller.abort();
+ await expect(p).rejects.toThrow("Aborted");
+ });
+
+ it("passes through when timeout is disabled (0) and no signal", async () => {
+ const result = await withTimeout(Promise.resolve("passthrough"), "initialize", 0);
+ expect(result).toBe("passthrough");
+ });
+
+ it("passes through when timeout is Infinity and no signal", async () => {
+ const result = await withTimeout(Promise.resolve(42), "tools/list", Number.POSITIVE_INFINITY);
+ expect(result).toBe(42);
+ });
+
+ it("still honors the signal when timeout is disabled", async () => {
+ const controller = new AbortController();
+ const p = withTimeout(new Promise<string>(() => {}), "initialize", 0, controller.signal);
+ controller.abort();
+ await expect(p).rejects.toThrow("Aborted");
+ });
+
+ it("removes the abort listener after the promise settles", async () => {
+ const controller = new AbortController();
+ const addSpy = controller.signal.addEventListener.bind(controller.signal);
+ let added = 0;
+ const spiedAdd = (...args: unknown[]) => {
+ added++;
+ return (addSpy as (...a: unknown[]) => void)(...args);
+ };
+ controller.signal.addEventListener = spiedAdd as typeof controller.signal.addEventListener;
+
+ await withTimeout(Promise.resolve("ok"), "initialize", 50_000, controller.signal);
+ // The resolve path cleans up: the listener was added then removed. The
+ // important assertion is that resolution works and no abort fires after.
+ expect(added).toBe(1);
+ // Aborting after settlement must NOT reject the already-settled promise.
+ controller.abort();
+ });
+});
+
+describe("McpTimeoutError", () => {
+ it("carries method + timeoutMs and a descriptive message", () => {
+ const err = new McpTimeoutError("initialize", 5000);
+ expect(err.method).toBe("initialize");
+ expect(err.timeoutMs).toBe(5000);
+ expect(err.message).toBe("MCP initialize timed out after 5000ms");
+ expect(err.name).toBe("McpTimeoutError");
+ });
+});
diff --git a/packages/mcp/src/timeout.ts b/packages/mcp/src/timeout.ts
new file mode 100644
index 0000000..294bfc5
--- /dev/null
+++ b/packages/mcp/src/timeout.ts
@@ -0,0 +1,114 @@
+/**
+ * Timeout + abort helper for MCP operations.
+ *
+ * A single misbehaving or framing-incompatible MCP server must never be able to
+ * hang an agent turn indefinitely: the JSON-RPC `initialize` / `tools/list`
+ * requests are awaited in-band during the per-turn tools filter, so a server
+ * that never responds would block the whole turn forever. `withTimeout` bounds
+ * any such awaited operation by BOTH a timeout (always) and an optional
+ * `AbortSignal` (so the turn's stop can interrupt an in-flight connect).
+ *
+ * Edge effect: uses `setTimeout` (the clock is the only I/O). The timer + the
+ * signal listener are always cleaned up on settlement, so a resolved operation
+ * never leaks a pending timer. The underlying promise ALWAYS has a handler
+ * attached (even when abort/timeout wins first), so it can never surface as an
+ * unhandled rejection. Mocking the OUTERMOST edge (real clock) is fine; tests
+ * drive this via `AbortController` (deterministic) rather than the timer.
+ */
+
+/** Default per-operation timeout for MCP handshake/tool-list requests (ms). */
+export const MCP_DEFAULT_TIMEOUT_MS = 30_000;
+
+/**
+ * Backstop timeout bounding the ENTIRE per-turn MCP connect phase (spawn +
+ * initialize + listTools across all configured servers), applied by the tools
+ * filter. A misbehaving or framing-incompatible server cannot hang a turn
+ * longer than this; on expiry the filter degrades gracefully (skips MCP tools
+ * for that turn) instead of blocking the turn. Generous enough to absorb a
+ * legitimate slow server startup (e.g. a browser-launching MCP server).
+ */
+export const MCP_CONNECT_TIMEOUT_MS = 30_000;
+
+/**
+ * Raised when an MCP operation does not settle within its timeout. Distinct
+ * from a plain `Error` so callers (the manager's broken-state tracking, tests)
+ * can tell a timeout/incompatibility apart from a server-reported RPC error.
+ */
+export class McpTimeoutError extends Error {
+ readonly method: string;
+ readonly timeoutMs: number;
+ constructor(method: string, timeoutMs: number) {
+ super(`MCP ${method} timed out after ${timeoutMs}ms`);
+ this.name = "McpTimeoutError";
+ this.method = method;
+ this.timeoutMs = timeoutMs;
+ }
+}
+
+/**
+ * Race `promise` against a timeout (always) and an optional `AbortSignal`.
+ * Resolves/rejects with `promise`'s outcome if it settles first; rejects with
+ * `McpTimeoutError` on timeout, or `Error("Aborted")` if `signal` aborts first.
+ *
+ * @param method JSON-RPC method name (for the timeout message).
+ * @param timeoutMs Milliseconds before a timeout is raised. Pass `0` or
+ * `Infinity` to disable the timeout (only the `signal` then bounds the call).
+ * @param signal Optional abort signal — typically the turn's signal, so
+ * `POST /conversations/:id/stop` can interrupt a stuck connect immediately.
+ */
+export function withTimeout<T>(
+ promise: Promise<T>,
+ method: string,
+ timeoutMs: number,
+ signal?: AbortSignal,
+): Promise<T> {
+ // No timeout and no signal → pass straight through (nothing to race).
+ const hasTimeout = timeoutMs > 0 && Number.isFinite(timeoutMs);
+ if (!hasTimeout && signal === undefined) {
+ return promise;
+ }
+
+ return new Promise<T>((resolve, reject) => {
+ let settled = false;
+
+ const finish = (action: () => void): void => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ action();
+ };
+
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ const onAbort = (): void => {
+ finish(() => reject(new Error("Aborted")));
+ };
+
+ const cleanup = (): void => {
+ if (timer !== undefined) clearTimeout(timer);
+ if (signal !== undefined) signal.removeEventListener("abort", onAbort);
+ };
+
+ if (hasTimeout) {
+ timer = setTimeout(
+ () => finish(() => reject(new McpTimeoutError(method, timeoutMs))),
+ timeoutMs,
+ );
+ }
+ if (signal !== undefined) {
+ if (signal.aborted) {
+ // Already aborted: abort wins immediately. The `.then` below still
+ // attaches a handler so the underlying promise never rejects unhandled.
+ finish(() => reject(new Error("Aborted")));
+ } else {
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+ }
+
+ // Always attach handlers so the underlying promise is never unhandled —
+ // even when abort/timeout already won (settled), this is a no-op.
+ promise.then(
+ (value) => finish(() => resolve(value)),
+ (err: unknown) => finish(() => reject(err)),
+ );
+ });
+}
diff --git a/packages/mcp/src/transport.test.ts b/packages/mcp/src/transport.test.ts
index e828340..8c69ad2 100644
--- a/packages/mcp/src/transport.test.ts
+++ b/packages/mcp/src/transport.test.ts
@@ -62,7 +62,7 @@ describe("createStdioTransport", () => {
connection.close();
});
- it("connection sends framed messages via stdin", () => {
+ it("connection sends newline-delimited messages via stdin (current MCP spec)", () => {
const pair = makePipe();
const spawn: SpawnProcess = () => pair.process;
@@ -73,8 +73,33 @@ describe("createStdioTransport", () => {
const writes = pair.writtenToStdin();
expect(writes.length).toBe(1);
const text = new TextDecoder().decode(writes[0]);
- expect(text).toContain("Content-Length:");
+ // Outgoing framing is newline-delimited JSON (not Content-Length).
+ expect(text).not.toContain("Content-Length:");
expect(text).toContain('"method":"test/method"');
+ expect(text.endsWith("\n")).toBe(true);
+ connection.close();
+ });
+
+ it("decodes a newline-delimited server response (auto-detect)", async () => {
+ const pair = makePipe();
+ const spawn: SpawnProcess = () => pair.process;
+
+ const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp");
+
+ const resultPromise = connection.send("tools/list");
+
+ // Server responds with newline-delimited JSON (e.g. chrome-devtools-mcp).
+ const response = `${JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: { tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }] },
+ })}\n`;
+ pair.emitStdout(new TextEncoder().encode(response));
+
+ const result = await resultPromise;
+ expect(result).toEqual({
+ tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }],
+ });
connection.close();
});
diff --git a/packages/message-queue/src/index.ts b/packages/message-queue/src/index.ts
index 11467e1..ae0868b 100644
--- a/packages/message-queue/src/index.ts
+++ b/packages/message-queue/src/index.ts
@@ -10,6 +10,7 @@ export type { QueuedMessage, QueuePayload } from "@dispatch/wire";
export { extension, manifest } from "./extension.js";
export {
buildQueueSpec,
+ cancel,
combine,
drain,
enqueue,
diff --git a/packages/message-queue/src/pure.test.ts b/packages/message-queue/src/pure.test.ts
index 3fd6039..4fca0fa 100644
--- a/packages/message-queue/src/pure.test.ts
+++ b/packages/message-queue/src/pure.test.ts
@@ -2,6 +2,7 @@ import type { QueuedMessage } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
buildQueueSpec,
+ cancel,
combine,
drain,
enqueue,
@@ -98,6 +99,84 @@ describe("drain", () => {
});
});
+describe("cancel", () => {
+ it("removes the matching message and returns the post-cancel snapshot", () => {
+ const state: MessageQueueState = new Map();
+ const deps = makeDeps();
+ enqueue(state, "c1", "a", deps); // id-1
+ enqueue(state, "c1", "b", deps); // id-2
+ enqueue(state, "c1", "c", deps); // id-3
+
+ const snapshot = cancel(state, "c1", "id-2");
+ expect(snapshot.map((m) => m.id)).toEqual(["id-1", "id-3"]);
+ expect(snapshot.map((m) => m.text)).toEqual(["a", "c"]);
+ // live state reflects the removal
+ expect(getQueue(state, "c1").map((m) => m.id)).toEqual(["id-1", "id-3"]);
+ });
+
+ it("removing the only message drops the key (queue is empty + clean)", () => {
+ const state: MessageQueueState = new Map();
+ const deps = makeDeps();
+ enqueue(state, "c1", "only", deps); // id-1
+
+ const snapshot = cancel(state, "c1", "id-1");
+ expect(snapshot).toEqual([]);
+ expect(getQueue(state, "c1")).toEqual([]);
+ // key removed so a fresh getQueue is a clean empty (not a lingering [] key)
+ expect(state.has("c1")).toBe(false);
+ });
+
+ it("returns a COPY — mutating the snapshot does not affect live state", () => {
+ const state: MessageQueueState = new Map();
+ const deps = makeDeps();
+ enqueue(state, "c1", "a", deps);
+ enqueue(state, "c1", "b", deps);
+
+ const snapshot = cancel(state, "c1", "id-1");
+ snapshot.push({ id: "evil", text: "mutate", queuedAt: 0 });
+ expect(getQueue(state, "c1")).toHaveLength(1);
+ });
+
+ it("is idempotent — cancelling a missing id is a no-op (returns snapshot without it)", () => {
+ const state: MessageQueueState = new Map();
+ const deps = makeDeps();
+ enqueue(state, "c1", "a", deps); // id-1
+
+ // unknown message id
+ const snapshot = cancel(state, "c1", "nope");
+ expect(snapshot.map((m) => m.id)).toEqual(["id-1"]);
+ expect(getQueue(state, "c1")).toHaveLength(1);
+
+ // a second cancel of the already-removed id-1 (re-add then cancel twice)
+ cancel(state, "c1", "id-1");
+ expect(getQueue(state, "c1")).toEqual([]);
+ expect(cancel(state, "c1", "id-1")).toEqual([]); // already gone — no-op
+ });
+
+ it("is scoped per conversation — cancelling on one conversation does not affect another", () => {
+ const state: MessageQueueState = new Map();
+ const deps = makeDeps();
+ enqueue(state, "c1", "a", deps); // id-1
+ enqueue(state, "c2", "b", deps); // id-2
+
+ const snapshot = cancel(state, "c1", "id-1");
+ expect(snapshot).toEqual([]);
+ expect(getQueue(state, "c1")).toEqual([]);
+ // c2 untouched
+ expect(getQueue(state, "c2").map((m) => m.id)).toEqual(["id-2"]);
+ });
+
+ it("cancelling on an unknown / empty conversation is a no-op (returns [])", () => {
+ const state: MessageQueueState = new Map();
+ expect(cancel(state, "unknown", "anything")).toEqual([]);
+ // unknown id on a conversation that exists but is empty post-drain
+ const deps = makeDeps();
+ enqueue(state, "c1", "a", deps);
+ drain(state, "c1"); // empties + deletes the key
+ expect(cancel(state, "c1", "id-1")).toEqual([]);
+ });
+});
+
describe("combine", () => {
it("combine joins texts with blank-line separator", () => {
const msgs: QueuedMessage[] = [
diff --git a/packages/message-queue/src/pure.ts b/packages/message-queue/src/pure.ts
index 981e005..ffb9dc2 100644
--- a/packages/message-queue/src/pure.ts
+++ b/packages/message-queue/src/pure.ts
@@ -75,6 +75,42 @@ export function drain(state: MessageQueueState, conversationId: string): QueuedM
}
/**
+ * Cancel: remove a SINGLE queued message by id from a conversation's queue so
+ * it never runs (never delivered as steering, never carried into a new turn).
+ * Returns the post-cancel queue snapshot (a fresh array copy). Idempotent — if
+ * no message with `messageId` exists in the conversation's queue (already
+ * drained/delivered, never existed, unknown conversation) the queue is
+ * unchanged and the returned snapshot simply does not contain it; the caller
+ * distinguishes "removed" from "not found" via the `cancelWithFlag` helper
+ * (this returns the snapshot only, like the other pure ops).
+ *
+ * The cancelled message is dropped entirely — it is NOT returned (the caller
+ * does not need it; the surface re-renders from the snapshot). Mutates `state`
+ * in place (splices the message out of the conversation's array).
+ */
+export function cancel(
+ state: MessageQueueState,
+ conversationId: string,
+ messageId: string,
+): QueuedMessage[] {
+ const existing = state.get(conversationId);
+ if (existing === undefined || existing.length === 0) {
+ return getQueue(state, conversationId);
+ }
+ const idx = existing.findIndex((m) => m.id === messageId);
+ if (idx === -1) {
+ return getQueue(state, conversationId);
+ }
+ existing.splice(idx, 1);
+ // If the queue is now empty, drop the key so `getQueue` stays a clean empty
+ // array (mirrors `drain` deleting the key on empty).
+ if (existing.length === 0) {
+ state.delete(conversationId);
+ }
+ return getQueue(state, conversationId);
+}
+
+/**
* Combine drained messages' texts into a single steering string, joined by a
* blank line (`\n\n`). Pure — the session-orchestrator builds the final
* ChatMessage from this.
diff --git a/packages/message-queue/src/service.test.ts b/packages/message-queue/src/service.test.ts
index aa59dd3..086414e 100644
--- a/packages/message-queue/src/service.test.ts
+++ b/packages/message-queue/src/service.test.ts
@@ -99,3 +99,72 @@ describe("message-queue service", () => {
expect(combine(drained)).toBe("alpha\n\nbeta");
});
});
+
+describe("message-queue service cancel", () => {
+ it("cancel removes the message and pushes a surface update (queue shrank)", () => {
+ const deps = makeDeps();
+ const svc = createMessageQueueService(deps);
+ svc.enqueue("c1", "a"); // q-1
+ svc.enqueue("c1", "b"); // q-2
+ svc.enqueue("c1", "c"); // q-3
+ expect(deps.calls.value).toBe(3); // three enqueues notified
+
+ const snapshot = svc.cancel("c1", "q-2");
+ expect(deps.calls.value).toBe(4); // cancel pushed a surface update
+ expect(snapshot.map((m) => m.id)).toEqual(["q-1", "q-3"]);
+
+ // live state reflects the removal
+ expect(svc.getQueue("c1").map((m) => m.id)).toEqual(["q-1", "q-3"]);
+ });
+
+ it("cancel of the only message empties the queue + pushes a surface update", () => {
+ const deps = makeDeps();
+ const svc = createMessageQueueService(deps);
+ svc.enqueue("c1", "only"); // q-1
+ expect(deps.calls.value).toBe(1);
+
+ const snapshot = svc.cancel("c1", "q-1");
+ expect(deps.calls.value).toBe(2); // surface update (queue → empty)
+ expect(snapshot).toEqual([]);
+ expect(svc.getQueue("c1")).toEqual([]);
+ });
+
+ it("cancel of a missing id does NOT push a surface update (no change)", () => {
+ const deps = makeDeps();
+ const svc = createMessageQueueService(deps);
+ svc.enqueue("c1", "a"); // q-1
+ expect(deps.calls.value).toBe(1);
+
+ // unknown message id — no-op, no notify
+ const snapshot = svc.cancel("c1", "nope");
+ expect(deps.calls.value).toBe(1); // unchanged — no change
+ expect(snapshot.map((m) => m.id)).toEqual(["q-1"]);
+
+ // unknown conversation — also a no-op, no notify
+ expect(svc.cancel("nope", "q-1")).toEqual([]);
+ expect(deps.calls.value).toBe(1); // still unchanged
+ });
+
+ it("cancel after a drain (queue empty) is a no-op with no surface update", () => {
+ const deps = makeDeps();
+ const svc = createMessageQueueService(deps);
+ svc.enqueue("c1", "a"); // q-1
+ svc.drain("c1");
+ expect(deps.calls.value).toBe(2); // enqueue + drain
+
+ expect(svc.cancel("c1", "q-1")).toEqual([]);
+ expect(deps.calls.value).toBe(2); // no notify — queue was already empty
+ });
+
+ it("cancel is scoped per conversation", () => {
+ const deps = makeDeps();
+ const svc = createMessageQueueService(deps);
+ svc.enqueue("c1", "a"); // q-1
+ svc.enqueue("c2", "b"); // q-2
+
+ const snapshot = svc.cancel("c1", "q-1");
+ expect(snapshot).toEqual([]);
+ expect(svc.getQueue("c1")).toEqual([]);
+ expect(svc.getQueue("c2").map((m) => m.id)).toEqual(["q-2"]);
+ });
+});
diff --git a/packages/message-queue/src/service.ts b/packages/message-queue/src/service.ts
index 97e270d..db12fd8 100644
--- a/packages/message-queue/src/service.ts
+++ b/packages/message-queue/src/service.ts
@@ -11,7 +11,12 @@
import { defineService, type Logger, type ServiceHandle } from "@dispatch/kernel";
import type { QueuedMessage } from "@dispatch/wire";
import type { MessageQueueState, QueueDeps } from "./pure.js";
-import { drain as drainQueue, enqueue as enqueueMessage, getQueue as readQueue } from "./pure.js";
+import {
+ cancel as cancelMessage,
+ drain as drainQueue,
+ enqueue as enqueueMessage,
+ getQueue as readQueue,
+} from "./pure.js";
/**
* The message-queue service interface. Obtained via
@@ -29,6 +34,14 @@ export interface MessageQueueService {
* was empty (and then NO surface update is pushed — no change).
*/
drain(conversationId: string): QueuedMessage[];
+ /**
+ * Cancel: remove a SINGLE queued message by id so it never runs (never
+ * delivered as steering, never carried into a new turn). Returns the
+ * post-cancel queue snapshot. A surface update is pushed ONLY when a message
+ * was actually removed (the queue shrank); cancelling a missing id is a
+ * no-op that pushes nothing (no change). Idempotent.
+ */
+ cancel(conversationId: string, messageId: string): QueuedMessage[];
}
/**
@@ -84,5 +97,23 @@ export function createMessageQueueService(deps: MessageQueueDeps): MessageQueueS
deps.notify();
return drained;
},
+ cancel(conversationId, messageId) {
+ // Notify ONLY on a real change (the queue shrank). Compare the pre-cancel
+ // length to the post-cancel snapshot — a missing id is a no-op that pushes
+ // no surface update (mirrors drain's no-notify-on-empty rule).
+ const beforeLen = readQueue(state, conversationId).length;
+ const snapshot = cancelMessage(state, conversationId, messageId);
+ if (snapshot.length === beforeLen) {
+ // nothing removed — no change, no surface push
+ return snapshot;
+ }
+ deps.logger?.debug("message-queue: cancelled", {
+ conversationId,
+ messageId,
+ queueLen: snapshot.length,
+ });
+ deps.notify();
+ return snapshot;
+ },
};
}
diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts
index 3d3e816..360e8d2 100644
--- a/packages/session-orchestrator/src/index.ts
+++ b/packages/session-orchestrator/src/index.ts
@@ -1,5 +1,6 @@
export { extension, manifest } from "./extension.js";
export {
+ type CancelQueuedMessageResult,
type CompactionService,
type ConversationClosedPayload,
type ConversationCompactedPayload,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 400ca0b..c4be03c 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -19,11 +19,14 @@ import type {
TurnMetrics,
} from "@dispatch/kernel";
import { createLogger, runTurn } from "@dispatch/kernel";
+import { createMessageQueueService } from "@dispatch/message-queue";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { describe, expect, it } from "vitest";
import {
+ type ConversationCompactedPayload,
type ConversationOpenedPayload,
type ConversationStatusChangedPayload,
+ conversationCompacted,
createCompactionService,
createSessionOrchestrator,
createWarmService,
@@ -49,6 +52,7 @@ function createInMemoryStore(): ConversationStore & {
const effortData = new Map<string, ReasoningEffort>();
const modelData = new Map<string, string>();
const workspaceIdData = new Map<string, string>();
+ const compactPercentData = new Map<string, number>();
// Track conversations that have a meta row. In the real store, append,
// setWorkspaceId, setConversationStatus, setConversationTitle, and
// setCompactedFrom all create a minimal meta row on first contact.
@@ -158,10 +162,12 @@ function createInMemoryStore(): ConversationStore & {
knownConversations.add(conversationId);
data.set(conversationId, [...messages]);
},
- async getCompactPercent() {
- return null;
+ async getCompactPercent(conversationId) {
+ return compactPercentData.get(conversationId) ?? null;
+ },
+ async setCompactPercent(conversationId, percent) {
+ compactPercentData.set(conversationId, percent);
},
- async setCompactPercent() {},
async forkHistory(_sourceId, targetId) {
knownConversations.add(targetId);
},
@@ -3873,6 +3879,194 @@ describe("system prompt: regular turn flow", () => {
});
});
+describe("title (summon-title): deferred until after workspace initialization", () => {
+ // Regression: an earlier implementation set the title in the HTTP /chat
+ // route BEFORE the turn started, which pre-created the conversation meta
+ // and made the orchestrator's `meta === null` newness check falsely report
+ // an EXISTING conversation — so ensureWorkspace / setWorkspaceId / the
+ // first-turn system-prompt construct were ALL skipped. The fix defers the
+ // title set into workspaceSetupPromise, AFTER the newness check + workspace
+ // assignment, so a titled new conversation is still initialized correctly.
+
+ /** Wrap the in-memory store to record the ORDER of init-relevant calls. */
+ function createCallRecordingStore() {
+ const base = createInMemoryStore();
+ const calls: string[] = [];
+ const titleCalls: { conversationId: string; title: string }[] = [];
+ return {
+ store: {
+ ...base,
+ async getConversationMeta(conversationId: string) {
+ calls.push(`getMeta:${conversationId}`);
+ return base.getConversationMeta(conversationId);
+ },
+ async ensureWorkspace(id: string) {
+ calls.push(`ensureWorkspace:${id}`);
+ return base.ensureWorkspace(id);
+ },
+ async setWorkspaceId(conversationId: string, workspaceId: string) {
+ calls.push(`setWorkspaceId:${workspaceId}`);
+ await base.setWorkspaceId(conversationId, workspaceId);
+ },
+ async setConversationTitle(conversationId: string, title: string) {
+ calls.push(`setTitle:${title}`);
+ titleCalls.push({ conversationId, title });
+ await base.setConversationTitle(conversationId, title);
+ },
+ } as ConversationStore,
+ calls,
+ titleCalls,
+ };
+ }
+
+ it("titled new conversation: workspace assigned, system prompt constructed, title set", async () => {
+ const { store, calls, titleCalls } = createCallRecordingStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+ const constructCalls: string[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(async (conversationId) => {
+ constructCalls.push(conversationId);
+ return "CONSTRUCTED_PROMPT";
+ }),
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-title-new",
+ text: "hi",
+ onEvent: () => {},
+ title: "My Task",
+ workspaceId: "my-workspace",
+ });
+
+ // The bug: workspace init was skipped. It must NOT be.
+ expect(calls).toContain("ensureWorkspace:my-workspace");
+ expect(calls).toContain("setWorkspaceId:my-workspace");
+ // First-turn system prompt construct runs (proves isNewConversation was
+ // true — the newness check was not fooled by a pre-created meta).
+ expect(constructCalls).toEqual(["conv-title-new"]);
+ // The title is persisted.
+ expect(titleCalls).toEqual([{ conversationId: "conv-title-new", title: "My Task" }]);
+ });
+
+ it("title is set AFTER the newness check + workspace assignment (order)", async () => {
+ const { store, calls } = createCallRecordingStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-title-order",
+ text: "hi",
+ onEvent: () => {},
+ title: "Ordered",
+ });
+
+ const getMetaIdx = calls.findIndex((c) => c.startsWith("getMeta:"));
+ const ensureIdx = calls.findIndex((c) => c.startsWith("ensureWorkspace:"));
+ const setWsIdx = calls.findIndex((c) => c.startsWith("setWorkspaceId:"));
+ const setTitleIdx = calls.findIndex((c) => c.startsWith("setTitle:"));
+ expect(getMetaIdx).toBeGreaterThanOrEqual(0);
+ expect(ensureIdx).toBeGreaterThan(getMetaIdx);
+ expect(setWsIdx).toBeGreaterThan(ensureIdx);
+ expect(setTitleIdx).toBeGreaterThan(setWsIdx);
+ });
+
+ it("no title: setConversationTitle is not called", async () => {
+ const { store, calls } = createCallRecordingStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-title",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(calls.some((c) => c.startsWith("setTitle:"))).toBe(false);
+ });
+
+ it("existing conversation with a title: workspace NOT re-assigned, title still set", async () => {
+ const { store, calls, titleCalls } = createCallRecordingStore();
+ // Seed an existing conversation (meta non-null, workspace already set).
+ await store.setWorkspaceId("conv-title-existing", "prior-workspace");
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-title-existing",
+ text: "hi",
+ onEvent: () => {},
+ title: "Renamed",
+ });
+
+ // Existing conversation: workspace init must not run again.
+ expect(calls.some((c) => c.startsWith("ensureWorkspace:"))).toBe(false);
+ // But the title is still applied (rename on an existing conversation).
+ expect(titleCalls).toEqual([{ conversationId: "conv-title-existing", title: "Renamed" }]);
+ });
+
+ it("turn still completes if setConversationTitle throws", async () => {
+ const base = createInMemoryStore();
+ const store: ConversationStore = {
+ ...base,
+ async setConversationTitle() {
+ throw new Error("title store unavailable");
+ },
+ };
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-title-throws",
+ text: "hi",
+ onEvent: () => {},
+ title: "Resilient",
+ });
+
+ // The turn ran despite the title-set failure.
+ expect(captured).toHaveLength(1);
+ });
+});
+
describe("system prompt: compaction flow", () => {
function seedHistory(
store: ReturnType<typeof createInMemoryStore>,
@@ -3985,6 +4179,484 @@ describe("system prompt: compaction flow", () => {
});
});
+describe("in-flight compaction", () => {
+ // Seeds a conversation with `count` alternating user/assistant text messages
+ // so the history is long enough to compact (> DEFAULT_KEEP_LAST_N = 10).
+ function seedHistory(
+ store: ReturnType<typeof createInMemoryStore>,
+ conversationId: string,
+ count: number,
+ ): void {
+ const messages: ChatMessage[] = [];
+ for (let i = 0; i < count; i++) {
+ messages.push({
+ role: i % 2 === 0 ? "user" : "assistant",
+ chunks: [{ type: "text", text: `seed message ${i}` }],
+ });
+ }
+ store.data.set(conversationId, messages);
+ }
+
+ // A provider whose `stream` serves a SCRIPT of per-call event lists, in
+ // order. Captures the messages passed to each call so a test can assert what
+ // the model saw at each step (incl. after in-flight compaction replaced it).
+ function createScriptedCapturingProvider(script: ProviderEvent[][]): {
+ provider: ProviderContract;
+ capturedMessages: ChatMessage[][];
+ } {
+ const capturedMessages: ChatMessage[][] = [];
+ let callIndex = 0;
+ const provider: ProviderContract = {
+ id: "fake",
+ stream(messages) {
+ capturedMessages.push([...messages]);
+ const events = script[callIndex] ?? [];
+ callIndex++;
+ return (async function* () {
+ for (const event of events) {
+ yield event;
+ }
+ })();
+ },
+ };
+ return { provider, capturedMessages };
+ }
+
+ function echoTool(): ToolContract {
+ return {
+ name: "echo",
+ description: "echo",
+ parameters: { type: "object" },
+ execute: async () => ({ content: "echoed" }),
+ };
+ }
+
+ it("triggers when a step's usage exceeds the threshold: history is compacted mid-turn and the prompt continues with the summary", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-inflight", 15); // > keepLastN(10) → compactable
+
+ // contextWindow 1000, default percent 85 → threshold 850.
+ // Step 0 emits a tool call + usage(inputTokens 900) → 910 > 850 → trigger.
+ // Then the compaction summary call, then step 1 ends the turn.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ // Compaction summary call (performCompaction):
+ [
+ { type: "text-delta", delta: "COMPACTED SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ // Step 1 (post-compaction) — the turn CONTINUES:
+ [
+ { type: "text-delta", delta: "all done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ const { events, onEvent } = collectEvents();
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-inflight",
+ text: "keep working overnight",
+ onEvent,
+ modelName: "test/model",
+ });
+
+ // 1) The conversationCompacted event fired mid-turn.
+ expect(compactedEvents).toHaveLength(1);
+ expect(compactedEvents[0]?.conversationId).toBe("conv-inflight");
+ expect(compactedEvents[0]?.messagesSummarized).toBeGreaterThan(0);
+ expect(compactedEvents[0]?.messagesKept).toBe(10);
+
+ // 2) The store history was replaced: it now begins with the system summary
+ // message, and the OLDEST seed messages are gone (summarized). The most
+ // recent messages are retained (keepLastN = 10), so some later seed
+ // messages may survive — that is correct.
+ const stored = store.data.get("conv-inflight") ?? [];
+ expect(stored.length).toBeGreaterThan(0);
+ expect(stored[0]?.role).toBe("system");
+ expect(stored[0]?.chunks[0]).toMatchObject({ type: "text" });
+ const firstText = (stored[0]?.chunks[0] as { text: string } | undefined)?.text ?? "";
+ expect(firstText).toContain("COMPACTED SUMMARY");
+ // The earliest seed messages were summarized away (not retained).
+ expect(
+ stored.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "seed message 0")),
+ ).toBe(false);
+
+ // 3) The turn CONTINUED after compaction: 3 provider calls happened
+ // (step 0, compaction summary, step 1) and the final assistant text
+ // was produced + persisted.
+ expect(capturedMessages).toHaveLength(3);
+ const step1Messages = capturedMessages[2] ?? [];
+ // Step 1 saw the COMPACTED history: it must start with the summary
+ // system message, NOT the original seed/user prefix.
+ expect(step1Messages[0]?.role).toBe("system");
+
+ const turnSealed = events.some((e) => e.type === "turn-sealed");
+ expect(turnSealed).toBe(true);
+ });
+
+ it("does NOT trigger when the step usage is below the threshold (history unchanged, no event)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-below", 15);
+
+ // contextWindow 1000 → threshold 850. Step usage 100 < 850 → no trigger.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 100, outputTokens: 5 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-below",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // No compaction event, and only 2 provider calls (no summary call).
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(2);
+ // The seed messages are still the start of history (uncompacted).
+ const stored = store.data.get("conv-below") ?? [];
+ expect(stored[0]?.chunks[0]).toMatchObject({ type: "text", text: "seed message 0" });
+ });
+
+ it("does NOT trigger when auto-compact is disabled (compact percent = 0)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-disabled", 15);
+ await store.setCompactPercent("conv-disabled", 0);
+
+ // Usage would exceed the default threshold, but percent=0 disables it.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-disabled",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(2);
+ });
+
+ it("does NOT trigger on a text-only turn (no tool calls → no next step → no boundary)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-textonly", 15);
+
+ // Single text-only step with high usage — but no tool calls → the turn
+ // ends → there is no step boundary to compact at (post-seal handles it).
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "text-delta", delta: "final answer" },
+ { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-textonly",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // No in-flight compaction (only 1 provider call — the turn ended).
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(1);
+ });
+
+ it("the manual compaction SERVICE still refuses while a conversation is generating, but in-flight compaction runs anyway", async () => {
+ // This documents the two-path design: compact() (the service) guards on
+ // activeConversations and refuses mid-turn; the in-flight path bypasses
+ // that guard (it IS the mid-turn path) using performCompaction directly.
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-twopath", 15);
+
+ const { provider } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+ const activeConversations = new Set<string>();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ // The compaction SERVICE shares the orchestrator's activeConversations set;
+ // build it against the SAME set so the guard reflects reality.
+ const compactionService = createCompactionService(
+ {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: () => {},
+ },
+ activeConversations,
+ );
+
+ // Drive a turn that triggers in-flight compaction. We can't easily inspect
+ // activeConversations mid-turn, so we assert the observable contract:
+ // in-flight compaction produced an event (it ran WHILE active), and the
+ // store was compacted.
+ await orchestrator.handleMessage({
+ conversationId: "conv-twopath",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ expect(compactedEvents).toHaveLength(1);
+ const stored = store.data.get("conv-twopath") ?? [];
+ expect(stored[0]?.role).toBe("system");
+
+ // After the turn settles (idle), the manual service CAN compact (no longer
+ // active) — and it succeeds (history is compactable again only if long
+ // enough; here it is short post-compaction, so it reports too-short, which
+ // proves the service path is reachable and its guard is the ONLY reason it
+ // would have refused mid-turn).
+ const manual = await compactionService.compact("conv-twopath");
+ // Post-compaction the history is short (summary + ~10 + turn tail) → the
+ // service reports an error (too short / threshold), NOT "generating".
+ expect("error" in manual).toBe(true);
+ if ("error" in manual) {
+ expect(manual.error).not.toBe("conversation is generating");
+ }
+ });
+
+ it("steering messages are persisted and survive in-flight compaction — the store and the LLM's context stay aligned (Bug A + B)", async () => {
+ // Regression test for the two critical bugs:
+ // A) drainSteering injected steering into the kernel's in-memory messages
+ // but never persisted it → the user could never see it, and
+ // compaction (loading the store) scrubbed it.
+ // B) compaction sliced the store and the kernel's messages independently;
+ // the unpersisted steering offset the slices → DB and LLM dropped
+ // DIFFERENT messages (structural divergence).
+ // Fix: drainSteering persists (awaited); compaction uses the kernel's LIVE
+ // messages array, so the store write and the kernel's replacement use the
+ // SAME recent slice → aligned, and the steering is retained.
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-align", 15); // > keepLastN(10) → compactable
+ const queue = createMessageQueueService({
+ id: () => `q-${Math.random().toString(36).slice(2, 8)}`,
+ now: () => 1000,
+ notify: () => {},
+ });
+ queue.enqueue("conv-align", "STEER MID-TURN"); // drained at step 0's boundary
+
+ // contextWindow 1000, percent 85 → threshold 850. Step 0 usage 900 → fire.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ // Compaction summary call:
+ [
+ { type: "text-delta", delta: "ALIGN SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ // Step 1 (post-compaction) — the turn continues:
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ resolveQueue: () => queue,
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-align",
+ text: "keep working overnight",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // Bug A: the steering message was PERSISTED to the store (the user CAN see
+ // it). It is either retained in the kept recent slice or captured in the
+ // summary; either way it must be present in the store, not lost.
+ expect(compactedEvents).toHaveLength(1);
+ const stored = store.data.get("conv-align") ?? [];
+ // The compacted history begins with the summary system message.
+ expect(stored[0]?.role).toBe("system");
+ expect((stored[0]?.chunks[0] as { text?: string } | undefined)?.text).toContain(
+ "ALIGN SUMMARY",
+ );
+ // The steering message survived in the kept recent slice (it was the most
+ // recent user message before compaction, so it is within keepLastN=10).
+ const storedSteering = stored.find(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(storedSteering).toBeDefined();
+
+ // Bug B: the store and the LLM's context are ALIGNED. The provider's
+ // post-compaction call (captured[2]) is the kernel's working history AFTER
+ // compaction replaced it. The store was written with the SAME compacted
+ // history, then step 1's assistant output was appended on top. So the
+ // kernel's view (captured[2]) must be an exact PREFIX of the store — same
+ // summary, same recent slice, same steering at the same index. (Before the
+ // fix, the store dropped the steering while the kernel kept it, so the two
+ // diverged structurally.)
+ const step1Messages = capturedMessages[2] ?? [];
+ expect(step1Messages[0]?.role).toBe("system"); // summary heads both
+ const kernelSteering = step1Messages.find(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(kernelSteering).toBeDefined();
+ // The kernel's post-compaction history is an exact PREFIX of the store
+ // (the store then has step 1's appended assistant output after it). Same
+ // length, same roles in order, same steering index => structural alignment.
+ expect(stored.length).toBeGreaterThanOrEqual(step1Messages.length);
+ const storePrefix = stored.slice(0, step1Messages.length);
+ expect(storePrefix).toHaveLength(step1Messages.length);
+ for (let i = 0; i < step1Messages.length; i++) {
+ expect(storePrefix[i]?.role).toBe(step1Messages[i]?.role);
+ }
+ const storeSteerIdx = storePrefix.findIndex(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ const kernelSteerIdx = step1Messages.findIndex(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(kernelSteerIdx).toBe(storeSteerIdx);
+ expect(kernelSteerIdx).toBeGreaterThanOrEqual(0);
+ });
+});
+
describe("per-turn memory telemetry", () => {
function capturingLogger(): { logger: Logger; records: LogRecord[] } {
let id = 0;
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index aaf418a..a2e141a 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -131,6 +131,16 @@ export interface StartTurnInput {
* system-prompt service when loaded).
*/
readonly systemPrompt?: string;
+ /**
+ * A human-readable title for the conversation tab. When provided, it is
+ * persisted via `setConversationTitle` AFTER the new-conversation workspace
+ * setup resolves (so the `meta === null` newness detection still fires and
+ * `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ * construction are NOT skipped) and BEFORE the first message append (so the
+ * append's auto-title does not overwrite it). Omit to keep the auto-derived
+ * title. The caller is responsible for trimming/validation.
+ */
+ readonly title?: string;
}
export type StartTurnResult =
@@ -170,6 +180,20 @@ export interface EnqueueResult {
readonly queue: readonly QueuedMessage[];
}
+/**
+ * Result of `SessionOrchestrator.cancelQueuedMessage`. `cancelled` is true when
+ * a message with the given id was found in the conversation's queue and removed
+ * (it will never run — never delivered as steering, never carried into a new
+ * turn). `cancelled` is false when the message was not in the queue (already
+ * drained/delivered, never existed, unknown conversation) OR when the
+ * message-queue extension isn't loaded (degraded — feature off). `queue` is the
+ * post-cancel snapshot (empty when no queue extension is loaded).
+ */
+export interface CancelQueuedMessageResult {
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
export type TurnEventListener = (event: AgentEvent) => void;
interface ActiveTurn {
@@ -331,6 +355,18 @@ export interface SessionOrchestrator {
* returned snapshot is empty (degraded — feature off).
*/
enqueue(input: EnqueueInput): EnqueueResult;
+ /**
+ * Cancel (remove) a SINGLE queued message by id so it never runs. The single
+ * entry transports call to cancel a queued steering message. Resolves the
+ * message-queue service lazily (same as `enqueue`); when the extension isn't
+ * loaded the call degrades to `{ cancelled: false, queue: [] }`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered)
+ * returns `{ cancelled: false, ... }` without error.
+ */
+ cancelQueuedMessage(input: {
+ readonly conversationId: string;
+ readonly messageId: string;
+ }): CancelQueuedMessageResult;
subscribe(conversationId: string, listener: TurnEventListener): () => void;
isActive(conversationId: string): boolean;
/**
@@ -370,6 +406,8 @@ export interface SessionOrchestrator {
systemPrompt?: string;
/** Images attached to this turn — see {@link StartTurnInput.images}. */
images?: readonly ImageInput[];
+ /** Conversation tab title — see {@link StartTurnInput.title}. */
+ title?: string;
}): Promise<void>;
}
@@ -548,6 +586,7 @@ export function createSessionOrchestrator(
workspaceId: string,
systemPromptOverride: string | undefined,
images: readonly ImageInput[] | undefined,
+ title: string | undefined,
): void {
const turnId = generateTurnId();
const promptStartedAt = deps.now?.() ?? Date.now();
@@ -567,14 +606,34 @@ export function createSessionOrchestrator(
// The newness flag is also reused to decide whether to construct
// (first turn) or get (subsequent turn) the system prompt — see the
// providerOpts assembly below.
+ //
+ // An explicit `title` (e.g. the CLI `--title` flag) is persisted HERE,
+ // AFTER the workspace setup resolves — deliberately NOT before the turn.
+ // Setting it earlier (e.g. in the HTTP route) would pre-create the meta
+ // row, make `meta !== null`, and fool this newness check into skipping
+ // `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ // construction. By deferring it to here, the title lands after the
+ // workspace is assigned but BEFORE the first message append (so the
+ // append's auto-title sees a non-"Untitled" title and preserves it).
const workspaceSetupPromise = (async (): Promise<boolean> => {
const meta = await deps.conversationStore.getConversationMeta(conversationId);
if (meta === null) {
await deps.conversationStore.ensureWorkspace(workspaceId);
await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
- return true;
}
- return false;
+ if (title !== undefined) {
+ // Best-effort: a title-set failure must NOT break the turn (the
+ // workspace setup above already succeeded). Log and continue — the
+ // append's auto-derived title applies instead.
+ try {
+ await deps.conversationStore.setConversationTitle(conversationId, title);
+ } catch (err) {
+ deps.logger?.child({ conversationId }).warn("orchestrator: title set failure", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return meta === null;
})();
// ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
@@ -760,6 +819,11 @@ export function createSessionOrchestrator(
conversationId,
...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ // Thread the turn's abort signal into the filter chain so a filter
+ // awaiting slow I/O (the MCP tools filter connecting to MCP servers)
+ // can be interrupted by POST /conversations/:id/stop instead of
+ // blocking the turn until its own timeout fires.
+ signal: controller.signal,
});
const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
const turnLogger = deps.logger?.child({ conversationId, turnId });
@@ -844,17 +908,33 @@ export function createSessionOrchestrator(
const drainSteering =
queue === undefined
? undefined
- : (): readonly ChatMessage[] => {
+ : async (): Promise<readonly ChatMessage[]> => {
const queued = queue.drain(conversationId);
if (queued.length === 0) return [];
const steerText = queued.map((q) => q.text).join("\n\n");
+ const steeringMessage: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: steerText }],
+ };
+ // Persist the injected steering message to the store as part
+ // of the SAME critical section as the injection, so it is
+ // never lost. Without this, the message would live only in
+ // the kernel's in-memory messages array (never persisted),
+ // so a user could never see it — and in-flight compaction
+ // (which loads the store) would scrub it. A fire-and-forget
+ // append would race with the next step's `onStepComplete`
+ // append and collide on the store's seq counter, so we
+ // `await` it (the kernel awaits drainSteering). Errors
+ // propagate (a DB failure ends the turn, matching
+ // `onStepComplete`'s behavior).
+ await deps.conversationStore.append(conversationId, [steeringMessage]);
emitToHub(conversationId, {
type: "steering",
conversationId,
turnId,
text: steerText,
});
- return [{ role: "user", chunks: [{ type: "text", text: steerText }] }];
+ return [steeringMessage];
};
// Vision handoff: transform the message list for the provider. When the
@@ -899,6 +979,81 @@ export function createSessionOrchestrator(
...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
...(deps.now !== undefined ? { now: deps.now } : {}),
...(drainSteering !== undefined ? { drainSteering } : {}),
+ // In-flight compaction: at every tool-result boundary the kernel
+ // calls this with the step's usage + the running messages. When the
+ // context size exceeds the compact-percent threshold (percent of the
+ // model's context window), the old history is summarized and
+ // replaced with [summary, ...recent] — mid-turn, without stopping —
+ // so a long-running turn (e.g. left overnight) does not run out of
+ // context. This is DISTINCT from the post-seal auto-compact below:
+ // that one runs AFTER the turn ends (preparing the next turn) and
+ // refuses while the conversation is active; this one runs DURING the
+ // turn (saving the running turn) and uses the live step usage (not
+ // persisted metrics, which are only written at turn end). When the
+ // threshold is not exceeded, compaction is disabled (percent 0), or
+ // the model's context window is unknown, it returns void and the
+ // kernel keeps its history unchanged (a strict no-op).
+ onStepBoundary: async ({ stepUsage, messages }) => {
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 0) return; // auto-compact disabled
+ // contextSize mirrors the persisted definition: this step's
+ // inputTokens + outputTokens (the prompt the NEXT step would
+ // inherit, grown by this step's output).
+ const contextSize = stepUsage.inputTokens + stepUsage.outputTokens;
+ if (effectiveModelName === undefined || deps.resolveModelInfo === undefined) return;
+ const info = await deps.resolveModelInfo(effectiveModelName);
+ if (info?.contextWindow === undefined) return;
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return; // threshold not exceeded
+
+ const keepLastN = DEFAULT_KEEP_LAST_N;
+ turnLogger?.info("compaction:in-flight", {
+ conversationId,
+ turnId,
+ contextSize,
+ threshold,
+ percent,
+ });
+ const outcome = await performCompaction(
+ {
+ conversationStore: deps.conversationStore,
+ resolveProvider: deps.resolveProvider,
+ ...(deps.resolveModel !== undefined ? { resolveModel: deps.resolveModel } : {}),
+ ...(deps.resolveSystemPrompt !== undefined
+ ? { resolveSystemPrompt: deps.resolveSystemPrompt }
+ : {}),
+ ...(deps.resolveConcurrencyLimiter !== undefined
+ ? { resolveConcurrencyLimiter: deps.resolveConcurrencyLimiter }
+ : {}),
+ ...(deps.logger !== undefined ? { logger: deps.logger } : {}),
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
+ // emit is required by performCompaction; fall back to a no-op
+ // when the orchestrator was constructed without one (tests).
+ emit: deps.emit ?? noopEmit,
+ },
+ conversationId,
+ // Pass the kernel's LIVE messages array (not a store reload):
+ // it includes mid-turn steering messages (now persisted by
+ // drainSteering) and is the authoritative prompt state. Using it
+ // for the split keeps the store write and the kernel's
+ // replacement aligned (same recent slice) — no DB↔LLM divergence.
+ { keepLastN, modelName: effectiveModelName, messages },
+ );
+ if ("error" in outcome) {
+ turnLogger?.warn("compaction:in-flight:skipped", {
+ conversationId,
+ turnId,
+ error: outcome.error,
+ });
+ return; // too short / empty summary / unknown model → no replacement
+ }
+ // Return the compacted history the kernel should adopt. This is
+ // EXACTLY what performCompaction wrote to the store
+ // ([summary, ...recent-from-live]), so the kernel's working
+ // history and the store stay byte-aligned.
+ return outcome.compactedMessages;
+ },
};
// Persist the user message at turn start so it has a seq
@@ -1021,6 +1176,7 @@ export function createSessionOrchestrator(
workspaceId,
systemPrompt,
images,
+ title,
}) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
@@ -1035,6 +1191,7 @@ export function createSessionOrchestrator(
workspaceId ?? "default",
systemPrompt,
images,
+ title,
);
const turn = activeTurns.get(conversationId);
const turnId = turn !== undefined ? turn.turnId : "";
@@ -1060,6 +1217,19 @@ export function createSessionOrchestrator(
return { startedTurn: false, queue: snapshot };
},
+ cancelQueuedMessage({ conversationId, messageId }) {
+ // When the message-queue extension isn't loaded this degrades: nothing to
+ // cancel, empty snapshot (feature off). Mirrors `enqueue`'s degraded path.
+ const queue = deps.resolveQueue?.();
+ if (queue === undefined) {
+ return { cancelled: false, queue: [] };
+ }
+ const beforeLen = queue.getQueue(conversationId).length;
+ const snapshot = queue.cancel(conversationId, messageId);
+ const cancelled = snapshot.length < beforeLen;
+ return { cancelled, queue: snapshot };
+ },
+
subscribe(conversationId, listener) {
let listeners = subscribers.get(conversationId);
if (listeners === undefined) {
@@ -1140,6 +1310,7 @@ export function createSessionOrchestrator(
workspaceId,
systemPrompt,
images,
+ title,
}) {
const turnInput: StartTurnInput = {
conversationId,
@@ -1151,6 +1322,7 @@ export function createSessionOrchestrator(
...(workspaceId !== undefined ? { workspaceId } : {}),
...(systemPrompt !== undefined ? { systemPrompt } : {}),
...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
};
const result = orchestrator.startTurn(turnInput);
if (!result.started) {
@@ -1296,6 +1468,16 @@ export function createWarmService(
const DEFAULT_KEEP_LAST_N = 10;
const DEFAULT_COMPACT_PERCENT = 85;
+/**
+ * No-op emit used as a fallback when the orchestrator is constructed without an
+ * `emit` (some tests). `performCompaction` requires a non-optional `emit` (it
+ * emits `conversationCompacted`); the in-flight path degrades to emitting
+ * nothing rather than skipping compaction entirely. Generic-typed so it
+ * satisfies `PerformCompactionDeps["emit"]` for any hook payload type.
+ */
+const noopEmit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void =
+ () => {};
+
const COMPACTION_SYSTEM_PROMPT =
"You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " +
"Focus on key decisions, context, file paths, and any unresolved questions. " +
@@ -1317,6 +1499,222 @@ function formatMessagesForSummary(messages: readonly ChatMessage[]): string {
.join("\n\n");
}
+/**
+ * Deps for {@link performCompaction} — the subset of `SessionOrchestratorDeps`
+ * needed to summarize old history, fork it to an archive, and replace it with
+ * a summary + recent messages. Structural so both the compaction SERVICE
+ * (`compact`, manual + post-seal auto) and the IN-FLIGHT compaction path (the
+ * turn loop's `onStepBoundary`) can call the same shared core without duplicating
+ * the summarization/fork/replace/emit logic. The active-conversation guard and
+ * the threshold check are the CALLERS' policy (they differ between the two
+ * paths) and are NOT performed here.
+ */
+interface PerformCompactionDeps {
+ readonly conversationStore: ConversationStore;
+ readonly resolveProvider: () => ProviderContract;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
+ readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ readonly logger?: Logger;
+ readonly now?: () => number;
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+}
+
+/** Result of a successful {@link performCompaction}. */
+interface PerformCompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+ /**
+ * The system-role summary message that heads the compacted history
+ * (`[summaryMessage, ...recentKept]`). Returned so the in-flight caller can
+ * build the kernel's replacement history with the SAME summary object.
+ */
+ readonly summaryMessage: ChatMessage;
+ /**
+ * The full compacted history `[summaryMessage, ...recentKept]` exactly as
+ * written to the store. The in-flight caller returns this to the kernel so
+ * the kernel's working history and the store stay byte-aligned (the same
+ * `recentKept` slice — taken from the caller-supplied live `messages` — is
+ * used for BOTH the store write and this return value).
+ */
+ readonly compactedMessages: readonly ChatMessage[];
+}
+
+/**
+ * The shared compaction core: summarize the oldest `history.length -
+ * keepLastN` messages via a provider stream, fork the full pre-compaction
+ * history to an archive (non-destructive), and replace the live history with
+ * `[summaryMessage, ...recentKept]`. Emits `conversationCompacted`. Returns the
+ * result (incl. the `summaryMessage` + the `compactedMessages`) or an error.
+ *
+ * History source: when `opts.messages` is provided (the in-flight path), it is
+ * used as the authoritative history — this is the kernel's LIVE messages array,
+ * which includes mid-turn steering messages (and the vision-transformed
+ * provider view) that a store reload could miss (the steering persist may not
+ * have completed, or — before this fix — was never done at all). Using the live
+ * array keeps the store write and the kernel's replacement aligned (same
+ * `recentKept` slice), avoiding the DB↔LLM structural divergence where
+ * independent slices dropped different messages. When `opts.messages` is
+ * omitted (the post-seal/manual `compact()` path — the turn has ended, so the
+ * store is stable), the history is loaded from the store.
+ *
+ * Performs NO active-conversation guard and NO threshold check — those are the
+ * callers' policy. No-ops (returns an error) when the conversation is too
+ * short to compact (≤ keepLastN messages) or the summary is empty.
+ */
+async function performCompaction(
+ deps: PerformCompactionDeps,
+ conversationId: string,
+ opts: {
+ readonly keepLastN?: number;
+ readonly modelName?: string;
+ /** The kernel's live messages array (in-flight path). Omit to load the store (post-seal/manual). */
+ readonly messages?: readonly ChatMessage[];
+ },
+): Promise<PerformCompactionResult | { readonly error: string }> {
+ // Use the caller-supplied live messages (in-flight) or load the store
+ // (post-seal/manual — the store is stable once the turn has ended).
+ const history = opts.messages ?? (await deps.conversationStore.load(conversationId));
+ const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
+
+ if (history.length <= keepLastN) {
+ return { error: "conversation too short to compact" };
+ }
+
+ // Split: old messages to summarize + recent messages to keep.
+ const toSummarize = history.slice(0, history.length - keepLastN);
+ const toKeep = history.slice(history.length - keepLastN);
+
+ // Resolve provider
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+ if (opts?.modelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(opts.modelName);
+ if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` };
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ // Wrap with concurrency limiting (same as the main turn path).
+ const compactionLimiter = deps.resolveConcurrencyLimiter?.();
+ if (compactionLimiter !== undefined) {
+ const compactionWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ provider = wrapProviderWithConcurrency(
+ provider,
+ compactionLimiter,
+ conversationId,
+ compactionWorkspaceId,
+ deps.now?.() ?? Date.now(),
+ );
+ }
+
+ // Build the summarization request: system prompt + conversation text + instruction
+ const conversationText = formatMessagesForSummary(toSummarize);
+ const summaryRequest: ChatMessage = {
+ role: "user",
+ chunks: [
+ {
+ type: "text",
+ text: `Please summarize the following conversation:\n\n${conversationText}`,
+ },
+ ],
+ };
+
+ const providerOpts: ProviderStreamOptions = {
+ maxTokens: 2000,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(deps.logger !== undefined
+ ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) }
+ : {}),
+ };
+
+ // Reconstruct the system prompt on compaction (fresh variable
+ // resolution — files/cwd/time may have changed since construction).
+ // The construct call also persists the result for future turns. When
+ // the system-prompt service is unavailable, fall back to the
+ // compaction-only system prompt (current behavior, no regression).
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ let compactionSystemPrompt: string;
+ if (systemPromptService !== undefined) {
+ const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
+ const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
+ const constructed = await systemPromptService.construct(conversationId, cwd, {
+ ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
+ workspaceId,
+ ...(computerId !== null ? { computerId } : {}),
+ });
+ compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
+ } else {
+ compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
+ }
+
+ // Call the provider and accumulate the summary
+ let summary = "";
+ for await (const event of provider.stream([summaryRequest], [], {
+ ...providerOpts,
+ systemPrompt: compactionSystemPrompt,
+ })) {
+ if ((event as ProviderEvent).type === "text-delta") {
+ summary += (event as { delta: string }).delta;
+ } else if ((event as ProviderEvent).type === "error") {
+ return { error: (event as { message: string }).message };
+ }
+ }
+
+ if (summary.trim().length === 0) {
+ return { error: "model produced empty summary" };
+ }
+
+ // Non-destructive: fork the full pre-compaction history to a new
+ // archive conversation. The original conversation keeps its ID
+ // (so messaging between agents still works) and gets the compacted
+ // content. The archive inherits the original's compactedFrom,
+ // creating a chain: A → Y → X → ...
+ const archiveId = crypto.randomUUID();
+ await deps.conversationStore.forkHistory(conversationId, archiveId);
+
+ // Replace history: [system: summary] + the recent kept messages. `toKeep`
+ // is sliced from the caller-supplied live `messages` (in-flight) — the SAME
+ // slice returned below as `compactedMessages` — so the store and the kernel's
+ // working history stay byte-aligned (same messages kept/dropped).
+ const summaryMessage: ChatMessage = {
+ role: "system",
+ chunks: [
+ {
+ type: "text",
+ text: `The following is a summary of the previous conversation:\n\n${summary}`,
+ },
+ ],
+ };
+
+ const compactedMessages: readonly ChatMessage[] = [summaryMessage, ...toKeep];
+ await deps.conversationStore.replaceHistory(conversationId, compactedMessages);
+ await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
+
+ deps.emit(conversationCompacted, {
+ conversationId,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ });
+
+ return {
+ summary,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ summaryMessage,
+ compactedMessages,
+ };
+}
+
export function createCompactionService(
deps: SessionOrchestratorDeps & {
readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
@@ -1329,14 +1727,9 @@ export function createCompactionService(
return { error: "conversation is generating" };
}
- const history = await deps.conversationStore.load(conversationId);
- const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
-
- if (history.length <= keepLastN) {
- return { error: "conversation too short to compact" };
- }
-
// Auto mode: check if contextSize exceeds percent of contextWindow.
+ // The threshold check is the caller's policy (uses persisted turn
+ // metrics) and is NOT performed by the shared `performCompaction` core.
if (opts?.auto === true) {
const stored = await deps.conversationStore.getCompactPercent(conversationId);
const percent = stored ?? DEFAULT_COMPACT_PERCENT;
@@ -1360,129 +1753,22 @@ export function createCompactionService(
if (contextSize < threshold) return { error: "threshold not exceeded" };
}
- // Split: old messages to summarize + recent messages to keep.
- const toSummarize = history.slice(0, history.length - keepLastN);
- const toKeep = history.slice(history.length - keepLastN);
-
- // Resolve provider
- let provider: ProviderContract;
- let modelOverride: string | undefined;
- if (opts?.modelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(opts.modelName);
- if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` };
- provider = resolved.provider;
- modelOverride = resolved.model;
- } else {
- provider = deps.resolveProvider();
- }
-
- // Wrap with concurrency limiting (same as the main turn path).
- const compactionLimiter = deps.resolveConcurrencyLimiter?.();
- if (compactionLimiter !== undefined) {
- const compactionWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
- provider = wrapProviderWithConcurrency(
- provider,
- compactionLimiter,
- conversationId,
- compactionWorkspaceId,
- deps.now?.() ?? Date.now(),
- );
- }
-
- // Build the summarization request: system prompt + conversation text + instruction
- const conversationText = formatMessagesForSummary(toSummarize);
- const summaryRequest: ChatMessage = {
- role: "user",
- chunks: [
- {
- type: "text",
- text: `Please summarize the following conversation:\n\n${conversationText}`,
- },
- ],
- };
-
- const providerOpts: ProviderStreamOptions = {
- maxTokens: 2000,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(deps.logger !== undefined
- ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) }
- : {}),
- };
-
- // Reconstruct the system prompt on compaction (fresh variable
- // resolution — files/cwd/time may have changed since construction).
- // The construct call also persists the result for future turns. When
- // the system-prompt service is unavailable, fall back to the
- // compaction-only system prompt (current behavior, no regression).
- const systemPromptService = deps.resolveSystemPrompt?.();
- let compactionSystemPrompt: string;
- if (systemPromptService !== undefined) {
- const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
- const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
- const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
- const constructed = await systemPromptService.construct(conversationId, cwd, {
- ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
- workspaceId,
- ...(computerId !== null ? { computerId } : {}),
- });
- compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
- } else {
- compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
- }
-
- // Call the provider and accumulate the summary
- let summary = "";
- for await (const event of provider.stream([summaryRequest], [], {
- ...providerOpts,
- systemPrompt: compactionSystemPrompt,
- })) {
- if ((event as ProviderEvent).type === "text-delta") {
- summary += (event as { delta: string }).delta;
- } else if ((event as ProviderEvent).type === "error") {
- return { error: (event as { message: string }).message };
- }
- }
-
- if (summary.trim().length === 0) {
- return { error: "model produced empty summary" };
- }
-
- // Non-destructive: fork the full pre-compaction history to a new
- // archive conversation. The original conversation keeps its ID
- // (so messaging between agents still works) and gets the compacted
- // content. The archive inherits the original's compactedFrom,
- // creating a chain: A → Y → X → ...
- const archiveId = crypto.randomUUID();
- await deps.conversationStore.forkHistory(conversationId, archiveId);
-
- // Replace history: [system: summary] + recent messages
- const summaryMessage: ChatMessage = {
- role: "system",
- chunks: [
- {
- type: "text",
- text: `The following is a summary of the previous conversation:\n\n${summary}`,
- },
- ],
- };
-
- await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]);
- await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
+ // Shared summarize + fork + replace + emit core (no active guard, no
+ // threshold — those are this caller's policy above). The length check
+ // ("conversation too short to compact") lives inside the core.
+ const outcome = await performCompaction(deps, conversationId, {
+ ...(opts?.keepLastN !== undefined ? { keepLastN: opts.keepLastN } : {}),
+ ...(opts?.modelName !== undefined ? { modelName: opts.modelName } : {}),
+ });
+ if ("error" in outcome) return { error: outcome.error };
+ const { summary, newConversationId, messagesSummarized, messagesKept } = outcome;
const result: CompactionResult = {
summary,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
+ newConversationId,
+ messagesSummarized,
+ messagesKept,
};
-
- deps.emit(conversationCompacted, {
- conversationId,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
- });
-
return result;
},
};
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index a09a441..e5746c3 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -217,7 +217,9 @@ function createDrainingCaptureRunTurn(): {
captured.push(input);
if (input.drainSteering !== undefined) {
drainCalled = true;
- const drained = input.drainSteering();
+ // The kernel awaits drainSteering (it may return a Promise that
+ // persists the injected messages); the fake mirrors that.
+ const drained = await input.drainSteering();
drainedMessages.push(...drained);
}
return {
@@ -332,6 +334,18 @@ describe("drainSteering", () => {
expect(steering?.conversationId).toBe("conv-drain");
expect(steering?.text).toBe("first\n\nsecond");
expect(steering?.turnId).toMatch(/^turn-/);
+
+ // The steering message was PERSISTED to the store (not just injected into
+ // the kernel's in-memory array). Without this, a user could never see the
+ // steering message, and in-flight compaction (which uses the live messages)
+ // would be the only thing keeping it — but only if it fired.
+ const stored = store.data.get("conv-drain") ?? [];
+ const storedSteering = stored.find(
+ (m) =>
+ m.role === "user" &&
+ m.chunks.some((c) => c.type === "text" && c.text === "first\n\nsecond"),
+ );
+ expect(storedSteering).toBeDefined();
});
it("drainSteering on an empty queue returns [] and emits nothing", async () => {
@@ -593,3 +607,183 @@ describe("enqueue", () => {
await sealed;
});
});
+
+// --- cancelQueuedMessage facade (remove a single queued message by id) ---
+
+describe("cancelQueuedMessage", () => {
+ it("removes a queued message by id → cancelled:true + post-cancel snapshot", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-cancel", "a");
+ queue.enqueue("conv-cancel", "b");
+ const second = queue.getQueue("conv-cancel")[1];
+ if (second === undefined) throw new Error("expected a second enqueued message");
+ const secondId = second.id;
+ expect(queue.getQueue("conv-cancel")).toHaveLength(2);
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-cancel",
+ messageId: secondId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue.map((m) => m.id)).not.toContain(secondId);
+ expect(result.queue).toHaveLength(1);
+ expect(queue.getQueue("conv-cancel").map((m) => m.id)).not.toContain(secondId);
+ });
+
+ it("cancel of the only message empties the queue (cancelled:true)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-only", "solo");
+ const onlyId = queue.getQueue("conv-only")[0]?.id;
+ if (onlyId === undefined) throw new Error("expected an enqueued message");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-only",
+ messageId: onlyId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue).toEqual([]);
+ expect(queue.getQueue("conv-only")).toEqual([]);
+ });
+
+ it("cancel of a missing id → cancelled:false, queue unchanged (idempotent)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-miss", "a");
+ queue.enqueue("conv-miss", "b");
+ const before = queue.getQueue("conv-miss");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-miss",
+ messageId: "does-not-exist",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue.map((m) => m.id)).toEqual(before.map((m) => m.id));
+ // live state unchanged
+ expect(queue.getQueue("conv-miss").map((m) => m.id)).toEqual(before.map((m) => m.id));
+ });
+
+ it("cancel on an unknown conversation → cancelled:false, empty queue", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "never-existed",
+ messageId: "anything",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("no queue ext (resolveQueue undefined) → cancelled:false, empty queue (degraded)", () => {
+ const store = createInMemoryStore();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ // resolveQueue intentionally omitted — feature degrades off
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-noqueue",
+ messageId: "whatever",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("cancelled message is NOT delivered as steering (never runs)", async () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-steer", "keep-me");
+ const cancelId = queue.enqueue("conv-steer", "cancel-me")[1]?.id;
+ if (cancelId === undefined) throw new Error("expected a second enqueued message");
+ // the first message id (kept)
+ const keepId = queue.getQueue("conv-steer")[0]?.id;
+ if (keepId === undefined) throw new Error("expected a kept message");
+
+ const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveQueue: () => queue,
+ });
+
+ // Cancel the second message BEFORE the turn drains.
+ const cancelResult = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-steer",
+ messageId: cancelId,
+ });
+ expect(cancelResult.cancelled).toBe(true);
+ expect(cancelResult.queue.map((m) => m.id)).toEqual([keepId]);
+
+ const events: AgentEvent[] = [];
+ const unsub = orchestrator.subscribe("conv-steer", (e) => events.push(e));
+
+ orchestrator.startTurn({ conversationId: "conv-steer", text: "go" });
+ await waitForSealed(orchestrator, "conv-steer");
+ unsub();
+
+ // The steering drain combined ONLY the kept message — the cancelled one
+ // is absent from the drained text.
+ expect(drainedMessages).toHaveLength(1);
+ const steerMsg = drainedMessages[0];
+ if (steerMsg === undefined) throw new Error("expected a drained message");
+ const chunk = steerMsg.chunks[0];
+ if (chunk === undefined || chunk.type !== "text") throw new Error("expected text chunk");
+ expect(chunk.text).toBe("keep-me");
+ expect(chunk.text).not.toContain("cancel-me");
+
+ // drainSteering was wired + the queue is now empty (the kept one drained).
+ expect(captured[0]?.drainSteering).toBeDefined();
+ expect(queue.getQueue("conv-steer")).toHaveLength(0);
+
+ // The steering event carries only the kept text.
+ const steering = events.find(isSteering);
+ expect(steering?.text).toBe("keep-me");
+ });
+});
diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts
index 28e82bf..22bc5cd 100644
--- a/packages/session-orchestrator/src/tools-filter.ts
+++ b/packages/session-orchestrator/src/tools-filter.ts
@@ -15,6 +15,15 @@ export interface ToolAssembly {
readonly computerId?: string;
/** The conversation this turn belongs to. */
readonly conversationId: string;
+ /**
+ * The turn's abort signal, threaded through the filter chain so a filter that
+ * awaits slow I/O (e.g. the MCP tools filter connecting to MCP servers) can be
+ * interrupted by `POST /conversations/:id/stop` instead of blocking the turn
+ * until its own timeout fires. Optional: omitted by paths that have no turn
+ * controller (e.g. the cache-warm probe), in which case filters fall back to
+ * their own timeouts. Filters that return a fresh assembly MUST preserve it.
+ */
+ readonly signal?: AbortSignal;
}
/** Filter chain run once per turn to transform the tool set before it reaches runTurn. */
@@ -55,5 +64,6 @@ export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssem
...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}),
...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}),
conversationId: assembly.conversationId,
+ ...(assembly.signal !== undefined ? { signal: assembly.signal } : {}),
};
}
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 660898a..0f65b7b 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.23.0",
+ "version": "0.24.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts
index 34ff544..3cc1b1e 100644
--- a/packages/transport-contract/src/contract.types.test.ts
+++ b/packages/transport-contract/src/contract.types.test.ts
@@ -68,6 +68,17 @@ const _chatWithHttpImage: ChatRequest = {
images: [{ url: "https://example.com/diagram.png" }],
};
+// ─── ChatRequest.title (additive optional) ───────────────────────────────────
+
+const _chatWithTitle: ChatRequest = {
+ message: "implement the feature",
+ title: "Summon: add --title flag",
+};
+
+const _chatWithoutTitle: ChatRequest = {
+ message: "hello",
+};
+
// ─── Computer list / single response ─────────────────────────────────────────
const _computer: Computer = {
@@ -285,6 +296,16 @@ describe("transport-contract types compile and are exported", () => {
expect(_chatWithHttpImage.images?.[0]?.mimeType).toBeUndefined();
});
+ // ─── ChatRequest.title (additive optional) ────────────────────────────────
+
+ it("ChatRequest: title is additive optional (omittable)", () => {
+ expect(_chatWithoutTitle.title).toBeUndefined();
+ });
+
+ it("ChatRequest: carries title when set", () => {
+ expect(_chatWithTitle.title).toBe("Summon: add --title flag");
+ });
+
it("ModelsResponse: ModelMetadata carries optional vision flag", () => {
const resp: ModelsResponse = {
models: ["umans/kimi-k2.7", "umans/glm-5.2"],
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 797ad22..32b03d3 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -121,6 +121,23 @@ export interface ChatRequest {
* defaultCwd = null).
*/
readonly workspaceId?: string;
+
+ /**
+ * A human-readable title for the conversation tab — persisted at creation
+ * time, after the new-conversation workspace setup resolves (so workspace
+ * assignment and first-turn system-prompt construction are not skipped) and
+ * before the first message append (so the append's auto-derived title does
+ * not overwrite it). The tab shows it instead of the default derived from
+ * the first message (`"Untitled"` until the first append). Omit to keep the
+ * auto-derived title. When present, the value is trimmed server-side; a
+ * whitespace-only value is treated as absent (auto-derive). A non-string
+ * value → HTTP 400 `{ error }`.
+ *
+ * Backward compatible — clients that omit it are unaffected. Mirrors the
+ * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the
+ * turn (no second round-trip from the client).
+ */
+ readonly title?: string;
}
/**
@@ -468,6 +485,27 @@ export interface QueueResponse {
readonly queue: readonly QueuedMessage[];
}
+/**
+ * Response body for
+ * `DELETE /conversations/:id/queue/:messageId` — cancel (remove) a single
+ * queued steering message by id so it never runs.
+ *
+ * `cancelled` is `true` when a message with the given id was found in the
+ * conversation's queue and removed (it will never be delivered as steering nor
+ * carried into a new turn). `cancelled` is `false` when the message was not in
+ * the queue (already drained/delivered, never existed, unknown conversation)
+ * OR when the message-queue extension isn't loaded (degraded — feature off).
+ * `queue` is the post-cancel snapshot (empty when no queue extension is
+ * loaded). Idempotent — cancelling a message that is no longer queued returns
+ * `cancelled: false` with HTTP 200 (not an error), so a client may optimistically
+ * fire-and-forget a cancel and reconcile from the surface.
+ */
+export interface QueueCancelResponse {
+ readonly conversationId: string;
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
// ─── Per-conversation LSP status ──────────────────────────────────────────────
/** The connection state of a single language server for a workspace. */
@@ -688,6 +726,24 @@ export interface ChatQueueMessage {
}
/**
+ * Client → server: cancel (remove) a SINGLE queued steering message by id so
+ * it never runs. The WebSocket counterpart of the HTTP
+ * `DELETE /conversations/:id/queue/:messageId` (`QueueCancelResponse`).
+ * Fire-and-forget: success is confirmed by the message-queue SURFACE updating
+ * (the cancelled message leaves the snapshot); a failure (missing/empty
+ * `conversationId` or `messageId`) arrives as a `chat.error`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered) is
+ * a silent no-op (no surface update, no error). `messageId` is the stable
+ * client-visible `QueuedMessage.id` (obtained from the queue surface snapshot
+ * or the enqueue response).
+ */
+export interface ChatQueueCancelMessage {
+ readonly type: "chat.queue.cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
+}
+
+/**
* Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat
* ops. A server discriminates on `type`.
*/
@@ -696,7 +752,8 @@ export type WsClientMessage =
| ChatSendMessage
| ChatSubscribeMessage
| ChatUnsubscribeMessage
- | ChatQueueMessage;
+ | ChatQueueMessage
+ | ChatQueueCancelMessage;
/**
* Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
@@ -968,10 +1025,26 @@ export interface TestComputerResponse {
* level. The scheduler resets its timer after each run completes (not a fixed
* wall-clock schedule); on backend restart it resumes scheduling for enabled
* heartbeats.
+ *
+ * `inactiveOnly` (default `true`) gates each fire on the configured workspace
+ * having NO active agents (no conversation with status `"active"` or `"queued"`):
+ * the heartbeat stays quiet while the user is actively working, and only fires
+ * when the workspace is idle. Set `false` to fire unconditionally.
*/
export interface HeartbeatConfig {
/** Whether the heartbeat loop is active for this workspace. */
readonly enabled: boolean;
+ /**
+ * When `true` (the default), the heartbeat SKIPS a fire when the configured
+ * workspace has any active agents — conversations whose persisted status is
+ * `"active"` (driving a turn) or `"queued"` (waiting on the message queue).
+ * The fire is silently skipped (no run is recorded); the scheduler re-arms
+ * and tries again at the next interval. When `false`, the heartbeat fires
+ * unconditionally regardless of workspace activity. The spawned heartbeat
+ * conversation lives in the DEDICATED heartbeat workspace, so it never
+ * counts as an "active agent" of the configured workspace (no self-block).
+ */
+ readonly inactiveOnly: boolean;
/** Custom system prompt for the heartbeat AI (empty = no system prompt). */
readonly systemPrompt: string;
/** Task prompt sent as the first user message when the heartbeat fires. */
@@ -993,6 +1066,7 @@ export interface HeartbeatConfig {
*/
export interface UpdateHeartbeatRequest {
readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
readonly systemPrompt?: string;
readonly taskPrompt?: string;
readonly intervalMinutes?: number;
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 03f1959..557fb44 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -15,6 +15,7 @@ import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
DeleteWorkspaceResponse,
+ QueueCancelResponse,
QueuedMessage,
QueueResponse,
SystemPromptVariable,
@@ -273,6 +274,9 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -309,6 +313,9 @@ function createCapturingOrchestrator(): SessionOrchestrator & {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -335,6 +342,9 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator {
enqueue() {
return { startedTurn: false, queue: [] };
},
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
closeConversation() {
return { abortedTurn: false };
},
@@ -539,6 +549,35 @@ function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService
};
}
+/**
+ * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId +
+ * partial update) and returns a config echoing the captured update on top of
+ * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards
+ * validated fields to the service.
+ */
+function createCapturingHeartbeatService(): HeartbeatService & {
+ readonly captured: { workspaceId: string; update: Record<string, unknown> }[];
+} {
+ const captured: { workspaceId: string; update: Record<string, unknown> }[] = [];
+ const svc: HeartbeatService = {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ async updateConfig(workspaceId, update) {
+ captured.push({ workspaceId, update: update as Record<string, unknown> });
+ return { ...DEFAULT_HEARTBEAT_CONFIG, ...update };
+ },
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => null,
+ };
+ return Object.assign(svc, {
+ get captured() {
+ return captured;
+ },
+ });
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -789,6 +828,124 @@ describe("POST /chat", () => {
expect(cap.received?.modelName).toBeUndefined();
expect(cap.received?.cwd).toBeUndefined();
});
+
+ it("forwards the title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.title).toBe("My Task");
+ });
+
+ it("forwards a trimmed title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " spaced " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBe("spaced");
+ });
+
+ it("does not forward a title when omitted", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("does not forward a title for a whitespace-only title", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("returns 400 when title is not a string", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("does not call setConversationTitle itself (the orchestrator owns it)", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ // The route must NOT pre-create the meta — that would bypass the
+ // orchestrator's new-conversation workspace/system-prompt init. The
+ // orchestrator sets the title after workspace setup instead.
+ expect(setTitleCalled).toBe(false);
+ });
});
describe("POST /chat/warm", () => {
@@ -2069,6 +2226,142 @@ describe("POST /conversations/:id/queue", () => {
});
});
+describe("DELETE /conversations/:id/queue/:messageId", () => {
+ it("when a message is cancelled → 200 + QueueCancelResponse (cancelled:true + post-cancel queue)", async () => {
+ const remaining: readonly QueuedMessage[] = [
+ { id: "q1", text: "kept", queuedAt: 1700000000000 },
+ ];
+ let received: { conversationId: string; messageId: string } | undefined;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage(input) {
+ received = input;
+ return { cancelled: true, queue: remaining };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/q2", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cancelled).toBe(true);
+ expect(body.queue).toEqual(remaining);
+ // forwards the path conversationId + messageId
+ expect(received?.conversationId).toBe("conv1");
+ expect(received?.messageId).toBe("q2");
+ });
+
+ it("when the message is not in the queue → 200 cancelled:false (idempotent, not an error)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "still-queued", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/missing", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("when the queue ext is not loaded → 200 cancelled:false, empty queue (degraded)", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/whatever", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("delegates the cancel to the orchestrator (never reads the body)", async () => {
+ let calls = 0;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ calls += 1;
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // No Content-Type / body — the endpoint takes the messageId from the path.
+ const res = await app.request("/conversations/conv-x/queue/m1", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toBe(1);
+ });
+
+ it("logs an info line on success and never logs the message text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue/q-secret", { method: "DELETE" });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: cancelled queued message");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.messageId).toBe("q-secret");
+ expect(infoLogs[0]?.attrs?.cancelled).toBe(true);
+ });
+});
+
describe("GET /conversations/:id/cwd", () => {
it("returns null when unset", async () => {
const app = createApp({
@@ -4541,3 +4834,64 @@ describe("GET /workspaces/:id/heartbeat/next-run", () => {
expect(body.nextRunAt).toBeNull();
});
});
+
+describe("PUT /workspaces/:id/heartbeat", () => {
+ it("forwards inactiveOnly to the service and echoes it in the response", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: false }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { inactiveOnly: boolean };
+ expect(body.inactiveOnly).toBe(false);
+ expect(hb.captured).toHaveLength(1);
+ expect(hb.captured[0]?.workspaceId).toBe("ws-1");
+ expect(hb.captured[0]?.update.inactiveOnly).toBe(false);
+ });
+
+ it("rejects a non-boolean inactiveOnly with 400", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: "yes" }),
+ });
+ expect(res.status).toBe(400);
+ // The service was NOT called (validation happened first).
+ expect(hb.captured).toHaveLength(0);
+ });
+
+ it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined();
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 656be9d..32a92f1 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -29,6 +29,7 @@ import type {
ModelResponse,
ModelsResponse,
OpenConversationResponse,
+ QueueCancelResponse,
QueueResponse,
ReasoningEffortResponse,
SetCompactPercentRequest,
@@ -456,6 +457,7 @@ export function createApp(opts: CreateServerOptions): Hono {
reasoningEffort,
workspaceId,
images,
+ title,
} = result;
log.info("chat: request accepted", {
conversationId,
@@ -516,6 +518,7 @@ export function createApp(opts: CreateServerOptions): Hono {
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
};
opts.orchestrator
@@ -796,6 +799,28 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json(response, 200);
});
+ app.delete("/conversations/:id/queue/:messageId", (c) => {
+ const conversationId = c.req.param("id");
+ const messageId = c.req.param("messageId");
+
+ // `cancelQueuedMessage` is synchronous and owns the lookup + removal (no
+ // separate race — the pure `cancel` is idempotent). It does not throw for an
+ // unknown/idle conversation, which instead returns cancelled:false. Mirrors
+ // the direct sync call used by `POST /conversations/:id/queue`.
+ const { cancelled, queue } = opts.orchestrator.cancelQueuedMessage({
+ conversationId,
+ messageId,
+ });
+ log.info("conversations: cancelled queued message", {
+ conversationId,
+ messageId,
+ cancelled,
+ queueLength: queue.length,
+ });
+ const response: QueueCancelResponse = { conversationId, cancelled, queue };
+ return c.json(response, 200);
+ });
+
app.get("/conversations/:id/cwd", async (c) => {
const conversationId = c.req.param("id");
try {
@@ -1673,6 +1698,16 @@ export function createApp(opts: CreateServerOptions): Hono {
update.enabled = obj.enabled;
}
+ // inactiveOnly: when true (the default), the heartbeat skips a fire while
+ // the configured workspace has active agents. A boolean; absent leaves it
+ // unchanged.
+ if (obj.inactiveOnly !== undefined) {
+ if (typeof obj.inactiveOnly !== "boolean") {
+ return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400);
+ }
+ update.inactiveOnly = obj.inactiveOnly;
+ }
+
if (obj.systemPrompt !== undefined) {
if (typeof obj.systemPrompt !== "string") {
return c.json({ error: "Field 'systemPrompt' must be a string" }, 400);
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index f424e42..effbadd 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -56,6 +56,7 @@ export const manifest: Manifest = {
"/conversations/:id/mcp",
"/conversations/:id/open",
"/conversations/:id/queue",
+ "/conversations/:id/queue/:messageId",
"/conversations/:id/reasoning-effort",
"/conversations/:id/status",
"/conversations/:id/stop",
diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts
index 67632f3..271ee96 100644
--- a/packages/transport-http/src/logic.test.ts
+++ b/packages/transport-http/src/logic.test.ts
@@ -183,6 +183,56 @@ describe("parseChatBody", () => {
}
});
+ // ── title ────────────────────────────────────────────────────────────────
+
+ it("extracts title when present", () => {
+ const result = parseChatBody({ message: "hi", title: "My Task" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("My Task");
+ }
+ });
+
+ it("trims title whitespace", () => {
+ const result = parseChatBody({ message: "hi", title: " spaced title " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("spaced title");
+ }
+ });
+
+ it("omits title when absent (backward compatible)", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when whitespace-only (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: " " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when empty string (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: "" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("returns error when title is not a string", () => {
+ const result = parseChatBody({ message: "hi", title: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("title");
+ }
+ });
+
// ── images ──────────────────────────────────────────────────────────────
it("parses images array with data URLs", () => {
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index c97f320..c703049 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -56,6 +56,16 @@ export interface ChatCommand {
readonly reasoningEffort?: ReasoningEffort;
readonly workspaceId?: string;
/**
+ * A human-readable title for the conversation tab, set at creation time.
+ * Parsed from the `ChatRequest.title` field; trimmed server-side. A
+ * whitespace-only value is treated as absent (omitted) so the auto-derived
+ * title applies. Forwarded to the orchestrator, which persists it via the
+ * conversation store's `setConversationTitle` AFTER the new-conversation
+ * workspace setup (so workspace assignment / first-turn system-prompt
+ * construction are not skipped) and before the first message append.
+ */
+ readonly title?: string;
+ /**
* Images attached to this turn (data URLs or http URLs). Parsed from the
* `ChatRequest.images` field; forwarded to the orchestrator which converts
* them to `image` chunks on the user message. Each entry must have a non-empty
@@ -128,6 +138,18 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes
(result as { workspaceId?: string }).workspaceId = obj.workspaceId;
}
+ if (obj.title !== undefined) {
+ if (typeof obj.title !== "string") {
+ return { error: "Field 'title' must be a string" };
+ }
+ const title = obj.title.trim();
+ // A whitespace-only title is treated as absent so the auto-derived title
+ // applies (mirrors omitting the field) — never persist an empty title.
+ if (title.length > 0) {
+ (result as { title?: string }).title = title;
+ }
+ }
+
if (obj.images !== undefined) {
if (!Array.isArray(obj.images)) {
return { error: "Field 'images' must be an array" };
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index d26712b..88f721e 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -345,6 +345,23 @@ export function createTransportWsExtension(): Extension {
break;
}
+ case "chat-queue-cancel": {
+ // Fire-and-forget: success is confirmed by the message-queue
+ // SURFACE updating (the cancelled message leaves the snapshot),
+ // NOT by a reply here. Cancelling a message that is no longer
+ // queued is a silent no-op (no surface update, no error).
+ const cancelResult = orchestrator.cancelQueuedMessage({
+ conversationId: result.conversationId,
+ messageId: result.messageId,
+ });
+ logger.info?.("transport-ws: chat.queue.cancel accepted", {
+ conversationId: result.conversationId,
+ messageId: result.messageId,
+ cancelled: cancelResult.cancelled,
+ });
+ break;
+ }
+
case "chat-error": {
logger.warn?.("transport-ws: malformed chat.send", {
reason: result.errorMessage,
diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts
index 3c3e70b..19b5bb5 100644
--- a/packages/transport-ws/src/router.test.ts
+++ b/packages/transport-ws/src/router.test.ts
@@ -604,6 +604,59 @@ describe("routeClientMessage", () => {
});
});
+ describe("chat.queue.cancel", () => {
+ it("routes a valid chat.queue.cancel → { kind: 'chat-queue-cancel', conversationId, messageId }", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "conv-1",
+ messageId: "q-42",
+ });
+
+ expect(result).toEqual({
+ kind: "chat-queue-cancel",
+ conversationId: "conv-1",
+ messageId: "q-42",
+ });
+ });
+
+ it("rejects empty conversationId → chat-error (no cancel signal)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "",
+ messageId: "q-42",
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ expect(result.errorMessage).toContain("conversationId");
+ });
+
+ it("rejects empty messageId → chat-error (no cancel signal)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ for (const messageId of ["", undefined as unknown as string]) {
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "conv-1",
+ messageId,
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ expect(result.errorMessage).toContain("messageId");
+ }
+ });
+ });
+
describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => {
// Every WsClientMessage variant must route to a defined result with a
// known kind — no fall-through / undefined return. If the union is
@@ -622,6 +675,7 @@ describe("routeClientMessage", () => {
{ type: "chat.subscribe", conversationId: "c1" },
{ type: "chat.unsubscribe", conversationId: "c1" },
{ type: "chat.queue", conversationId: "c1", text: "steer" },
+ { type: "chat.queue.cancel", conversationId: "c1", messageId: "m1" },
];
const validKinds = new Set<RouteResult["kind"]>([
@@ -631,6 +685,7 @@ describe("routeClientMessage", () => {
"chat-subscribe",
"chat-unsubscribe",
"chat-queue",
+ "chat-queue-cancel",
]);
for (const msg of samples) {
diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts
index 0caf305..014db96 100644
--- a/packages/transport-ws/src/router.ts
+++ b/packages/transport-ws/src/router.ts
@@ -9,6 +9,7 @@
import type { SurfaceContext, SurfaceRegistry } from "@dispatch/surface-registry";
import type {
+ ChatQueueCancelMessage,
ChatQueueMessage,
ChatSendMessage,
ChatSubscribeMessage,
@@ -99,6 +100,20 @@ export interface ChatQueueRouteResult {
readonly workspaceId?: string;
}
+/**
+ * The effect a validated chat.queue.cancel should produce. The shell calls
+ * `orchestrator.cancelQueuedMessage({ conversationId, messageId })` and emits
+ * NOTHING back (fire-and-forget): success is confirmed by the message-queue
+ * SURFACE updating (the cancelled message leaves the snapshot). Cancelling a
+ * message that is no longer queued is a silent no-op (no surface update, no
+ * error). Mirrors `ChatQueueRouteResult`'s fire-and-forget style.
+ */
+export interface ChatQueueCancelRouteResult {
+ readonly kind: "chat-queue-cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
+}
+
/** The effect any client WS message should produce. */
export type RouteResult =
| SurfaceRouteResult
@@ -106,7 +121,8 @@ export type RouteResult =
| ChatRouteError
| ChatSubscribeRouteResult
| ChatUnsubscribeRouteResult
- | ChatQueueRouteResult;
+ | ChatQueueRouteResult
+ | ChatQueueCancelRouteResult;
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -152,6 +168,8 @@ export function routeClientMessage(
return handleChatUnsubscribe(msg);
case "chat.queue":
return handleChatQueue(msg);
+ case "chat.queue.cancel":
+ return handleChatQueueCancel(msg);
}
}
@@ -253,6 +271,36 @@ function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRout
};
}
+/**
+ * Validate a chat.queue.cancel: both `conversationId` and `messageId` must be
+ * non-empty strings. Invalid → `chat-error` (the shell replies with `chat.error`,
+ * same style as a malformed `chat.queue`; the orchestrator is never called).
+ * Valid → `chat-queue-cancel` (the shell calls `orchestrator.cancelQueuedMessage`).
+ */
+function handleChatQueueCancel(
+ msg: ChatQueueCancelMessage,
+): ChatQueueCancelRouteResult | ChatRouteError {
+ if (typeof msg.conversationId !== "string" || msg.conversationId.length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.queue.cancel requires a non-empty string `conversationId`",
+ };
+ }
+ if (typeof msg.messageId !== "string" || msg.messageId.length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.queue.cancel requires a non-empty string `messageId`",
+ };
+ }
+ return {
+ kind: "chat-queue-cancel",
+ conversationId: msg.conversationId,
+ messageId: msg.messageId,
+ };
+}
+
// ── Per-message handlers ────────────────────────────────────────────────────
function handleSubscribe(
diff --git a/tasks.md b/tasks.md
deleted file mode 100644
index 137101a..0000000
--- a/tasks.md
+++ /dev/null
@@ -1,1050 +0,0 @@
-# Dispatch — tasks (live progress)
-
-> **Live status + roadmap only.** Completed milestones are summarized, not
-> narrated. Old blow-by-blow history is pruned — it lives in git (`git log`).
-> Keep this lean and current; do not let it re-accrete a step-by-step changelog.
-
-## Status (current)
-`tsc -b` EXIT 0 · biome clean · **1730 vitest** pass (+6 sshd-integration skipped). (worktree `feature/ssh-support`;
-merged `dev` — brings retry-with-backoff (`provider-retry` AgentEvent) + the LSP-dead-server fix alongside the
-SSH waves below.)
-
-## Retry with backoff on retryable provider errors (DONE — from dev)
-When the upstream LLM API returns a retryable error (HTTP 429 / 5xx "overloaded"),
-the kernel now retries `provider.stream()` with a stepped backoff, visibly, until
-the 8h cumulative-sleep budget is exhausted — then emits the final error and
-seals the turn. Retries fire ONLY when no content was emitted yet this step (the
-safety invariant — never duplicate partial output). Plan:
-`notes/retry-with-backoff-plan.md`; report: `reports/retry-with-backoff.md`.
-- **Architecture (kernel hook + shell policy/I/O):** kernel provides the hook
- (`RetryStrategy` contract + the retry loop in `runTurn`); the shell
- (session-orchestrator) provides the policy (the schedule) + the I/O (an
- abortable `setTimeout` sleep). Kernel imports no timer. `retry?` is optional
- → omit = no retry (backward-compatible).
-- **New transient `AgentEvent` variant** `provider-retry` (`@dispatch/wire`),
- emitted once per scheduled retry BEFORE the sleep so the UI can show
- "⚠ retrying in Ns…" immediately; NOT persisted to model history (never
- pollutes the prompt). Final failure is still a persisted `error` + seal.
-- **Schedule:** `5s,10s,30s,60s,5m,10m,15m,30m`, then repeat 30m until 8h of
- cumulative scheduled sleep → ~21 retries then give up. Pure `delayFor(attempt)`.
-- **Retry trigger:** emitted `error` with `retryable===true` → retry;
- `retryable` false/absent → give up; a THROWN error → retryable-by-default
- ONLY when pre-content. All gated on `!hadContent` (text/reasoning/tool-call/usage).
-- **Frontend handoff (5d3f, separate repo `../frontend`):** render
- `provider-retry` as a yellow warning system-message bubble showing `message`
- (+`code`) with the `delayMs` countdown.
-
-## SSH support — transparent remote execution (DONE — waves 0-5c)
-Plan: `notes/ssh-support-plan.md` (decisions locked in §0.5/§13). Orchestrated in
-waves (ORCHESTRATOR.md §2a — pre-author the contract seam, then parallel
-owner-agents on disjoint packages).
-- [x] **Wave 0** (orchestrator): kernel contract seam — `computerId` on
- `ToolExecuteContext` + `RunTurnInput` (additive optional; backward
- compatible). `tsc -b` EXIT 0.
-- [x] **Wave 1** (parallel): `wire` (Computer/defaultComputerId types) +
- `exec-backend` (NEW pkg: ExecBackend contract + LocalExecBackend + handle +
- resolver) + `kernel` runtime (thread computerId through dispatch/run-turn) +
- `conversation-store` (contract fan-out: defaultComputerId + getEffectiveComputer
- + per-conv computerId get/set/clear). `tsc -b` EXIT 0, biome clean, **1592 vitest**
- (was 1549, +43).
-- [x] **Wave 2** (parallel): refactor `tool-shell`/`read-file`/`write-file`/
- `edit-file` behind `ExecBackend` (local-only; spawn.ts deleted — logic moved
- to exec-backend; edit_file gains forward-compatible remote-diagnostics skip).
- `tsc -b` EXIT 0, biome clean, **1599 vitest** (was 1592).
-- [x] **Wave 3** (parallel): `session-orchestrator` (thread computerId end-to-end
- + remote tool-drop filter: drops `lsp` + `__`-namespaced MCP tools when
- remote) + `transport-contract` (ChatRequest.computerId + computer endpoint
- API types). `tsc -b` EXIT 0, biome clean, **1620 vitest** (was 1599).
-- [x] **Wave 4** (parallel): `transport-http` (computer endpoints + `/chat`
- threading + the `ComputerService` seam the ssh package will provide) +
- `transport-ws` (computerId through chat.send/queue) + `mcp` (CR-1: preserve
- computerId in filter). `tsc -b` EXIT 0, biome clean, **1641 vitest** (was 1620).
-- [x] **Wave 5a**: `exec-backend` — remote-backend factory handle (lazy lookup;
- computerId set -> SshExecBackend via factory; absent -> clear error). +24 tests.
-- [x] **Wave 5b**: `ssh` package (NEW) — SshConnectionPool (per-alias ssh2.Client,
- lazy connect, keep-alive, idle reap), SshExecBackend (ssh2 exec+sftp, node:fs
- .code error mapping), ~/.ssh/config reader (ssh-config), known_hosts
- auto-trust-and-pin, key-only auth from ~/.ssh. LOAD-BEARING: ssh2 verified
- under Bun (connected to local sshd :22, exec OK) — decision #1 confirmed.
- Provides remoteExecBackendFactoryHandle + computerServiceHandle. +45 tests
- (6 sshd integration tests skipped). tsc -b EXIT 0, biome clean, **1690 vitest**
- (was 1641).
-- [x] **Wave 5c**: host-bin — register exec-backend + ssh extensions in
- CORE_EXTENSIONS (correct DAG order); transport-http CR-5 barrel re-export of
- computerServiceHandle. orchestrator added missing @dispatch/exec-backend dep to
- host-bin + bun install. **LIVE-VERIFIED**: server boots clean ("Dispatch booted",
- no disabled extensions). tsc -b EXIT 0, biome clean, 1690 vitest (+6 sshd skipped).
-- [x] **Merge dev**: brought retry-with-backoff (`provider-retry` AgentEvent — what
- the FE consumes) + LSP-dead-server fix into the SSH branch. All code files
- auto-merged cleanly; only `tasks.md` conflicted (orchestrator-resolved).
-- [x] **FE handoff #3 (provider-retry merge) — RESOLVED**: FE re-synced both pinned
- file: deps (`@dispatch/wire` + `@dispatch/transport-contract`) against merged
- `feature/ssh-support`; both resolve `TurnProviderRetryEvent`. The 11 provider-
- retry svelte-check errors cleared with ZERO further FE code changes (consumer
- already complete + tested). FE full suite green: typecheck 0/0, 795/795 tests,
- biome clean, vite build OK. Earlier SSH handoffs (#1 wire types, #2 computer
- HTTP API) now also typecheck-clean against the merged wire. Nothing further
- needed from backend on this.
-- [x] **FE final sync check — GREEN, all three handoffs + cross-cutting verified**:
- FE confirmed whole-tree green (typecheck 0/0, 795/795 tests, biome clean, build
- OK, git clean). (1) provider-retry (§2c): TurnProviderRetryEvent resolves;
- assertAgentEventExhaustive covers it (typecheck-green = exhaustive); ChatView
- renders yellow alert-warning bubble w/ attemptLabel + delayLabel (delayMs via
- viewProviderRetry/formatRetryDelay) + code badge, gated {#if providerRetry}.
- (2) SSH handoff #1: Workspace.defaultComputerId + Computer/ComputerEntry resolve;
- 2 Workspace literals supply defaultComputerId: null; catalog flows through
- store.computers. (3) SSH handoff #2: full src/features/computer/ (ComputerField
- w/ per-conv selector + connection-status badge + Test-connection polling;
- ComputerSelect reusable; store computerId/refreshComputer/setComputer + computers
- catalog on boot + computerStatus/testComputer; WorkspaceCard default-computer
- selector via setDefaultComputer) — 20 view-model tests, typecheck-clean, chat.send
- unchanged. CROSS-CUTTING (key integration question): GREEN, no collision —
- provider-retry is WS-stream → TranscriptState.providerRetry → ChatView (transcript,
- keyed activeConversationId); computer is HTTP-ONLY (imports NO AgentEvent/chunks/
- TranscriptState) → AppStore.computerId (per-conv persisted) → ComputerField (sidebar,
- keyed currentConversationId). Disjoint state, disjoint channels (WS vs HTTP),
- disjoint regions (transcript vs sidebar), disjoint mount keys. The conversation-
- switch lifecycle is the only shared touchpoint and is correct + independent.
- assertAgentEventExhaustive confirms computer is NOT an AgentEvent (HTTP-only).
- We're done — nothing further needed from either side.
-- [ ] **DEFERRED — CR-6 usageCount**: `listComputers()` returns `usageCount: 0` until a
- conversation-store count-by-alias helper + host-bin wiring is added (non-blocking —
- discovery/connect/execute all work; only the count badge shows 0). Follow-up.
-- [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done
- (user-deferred — cache-warming is not needed right now). Known limitation:
- a warm probe on a remote turn assembles the tool set WITHOUT the remote-drop
- → a potential prompt-cache miss (performance-only, not correctness). Revisit
- when cache-warming is re-enabled.
-Key decisions: ssh2 + ssh-config (project-local deps); key-only auth from
-`~/.ssh`; auto-trust-and-pin host keys; computers discovered read-only from
-`~/.ssh/config` (no CRUD entity); computerId persisted per-conversation; LSP/MCP
-silently dropped on remote turns; edit_file works w/o diagnostics remotely.
-
-## Per-edit LSP diagnostics auto-append (DONE)
-After a successful `edit_file`, the extension now calls LSP `getDiagnostics` on the
-post-edit buffer and appends any errors/warnings (severity ≤ 2) to the tool result —
-so the model sees lint/diagnostics feedback inline without a separate round-trip.
-Multi-server aggregation queries ALL connected servers matching the file's extension
-(not just the first), merging diagnostics tagged by source (`[steep]`, `[ruby-lsp]`, etc.).
-Incremental sync (`textDocument/didChange`) captures each server's `change` kind during
-`initialize` and computes prefix/suffix diff ranges for `change:2` servers, full content
-for `change:1`. New pure `diff.ts` (`computeChangeRange` + `offsetToPosition`, O(n)).
-60s timeout; slow warning if >10s; graceful degradation when no LSP available. Generic
-— works for any LSP. `languageId` mapping extended (`.rb`/`.rbs`/`.c`/`.cpp`/etc.).
-- [x] Wave 1 — `packages/lsp/` (single unit): diff.ts, client, tool, diagnostics, language, types, extension. 15 new diff tests + multi-server tool test.
-- [x] Wave 2 — `packages/tool-edit-file/`: optional dep on `@dispatch/lsp` via `host.getService()` (not manifest `dependsOn`); appends diagnostics after successful edit.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1468 vitest** pass (was 1453, +15).
-- [x] **LIVE-VERIFIED** (production dispatch-server :24991): edit_file now surfaces LSP diagnostics inline — a deliberate type error (`const x: number = "not a number"`) in a .ts file produces `[TypeScript Language Server] ERROR (2322) L3:9: Type 'string' is not assignable to type 'number'` appended to the edit result. Required a lazy LSP service lookup fix (commits e03a96e + d4ff45c) — tool-edit-file activates at position 5 in CORE_EXTENSIONS while lsp activates at position 20, so getService always threw at activation time.
-
-## MCP (Model Context Protocol) integration (DONE)
-Dispatch is now an MCP host. A new `mcp` standard extension (`packages/mcp/`) spawns
-configured MCP servers (stdio child processes), performs the MCP handshake, discovers
-tools via `tools/list`, and registers each as a first-class Dispatch `ToolContract` via
-`host.defineTool`. When the model calls an MCP tool, the extension proxies to `tools/call`
-on the MCP server and returns the flattened result. Config: `.dispatch/mcp.json` (servers
-key) → `opencode.json` mcp key fallback, resolved per-cwd (mirrors LSP). Tool names namespaced
-as `<serverId>__<toolName>`. A `toolsFilter` drops tools from disconnected servers. Phase 1:
-stdio only, Tools only (no Resources/Prompts/HTTP/sampling). Hand-rolled JSON-RPC (zero deps).
-- **Design:** `notes/mcp-design.md` + `PLAN-mcp.md`.
-- [x] Wave 1 — `packages/mcp/` (agent via dispatch CLI): 12 source + 8 test files, 69 tests.
-- [x] Wave 2 — orchestrator: root tsconfig ref, host-bin CORE_EXTENSIONS registration, bun install.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1537 vitest** pass (was 1468, +69).
-- [x] **LIVE-VERIFIED** (production dispatch-server :24991): a minimal test MCP server (stdio,
- one `ping` tool) configured in `.dispatch/mcp.json` → model discovered `test__ping`,
- called it with `{"msg":"hello"}`, received `pong` — full turn lifecycle (tool-call →
- tool-result → done). Tool name namespacing (`<serverId>__<toolName>`) confirmed on the wire.
-- **Bug found + fixed during live-verify:** `edit_file` tool was missing from the toolset
- because the per-edit diagnostics change called `host.getService(lspServiceHandle)` at
- activation time, but `tool-edit-file` activates BEFORE `lsp` in CORE_EXTENSIONS → getService
- threw → activate crashed → tool never registered. Fix: lazy lookup at edit time (commits
- e03a96e, d4ff45c).
-
-## Broken-chat self-repair (read-time reconcile) (DONE)
-Conversation `77574596` broke unrecoverably: `reconcile()` only repaired orphaned
-tool-calls, not (a) a trailing assistant message whose only chunk is `error`
-(serializes to empty content → uncontinuable) and (b) a `tool-call` whose `input`
-is a raw malformed-JSON string (re-sent as OpenAI `arguments` → provider 400s on
-every continuation). `load()` also had no try/catch on `JSON.parse` (one corrupt
-row would brick a chat). Fix = read-time repair so broken chats auto-heal on next
-open — NO DB surgery (append-only preserved; repair is a turn-path transform on
-`load()`). Full diagnosis + plan: `broken-chat-repair-handoff.md` +
-`reports/broken-chat-repair-diagnosis.md`.
-- **Layer 1 — `conversation-store` `reconcile.ts` (protects ALL providers):**
- `reconcileWithReport` now (1) strips `error` chunks from assistant messages, (2)
- drops any assistant message left with no `text`/`tool-call` (the emptied error-only
- msg — safe: never followed by a `tool` msg), (3) keeps orphaned-tool-call synthesis
- unchanged. `ReconcileReport` +2 additive counts (`strippedErrorChunks`,
- `droppedEmptyMessages`) for the repair span. `loadSince` (FE reads) intentionally
- NOT reconciled — the user still SEES the error while the provider gets clean history.
- **Hardening:** `store.ts` `load()` wraps per-chunk `JSON.parse` in try/catch →
- corrupt row skipped (log + continue), reconcile runs on the rest. +6 reconcile/store
- tests.
-- **Layer 2 — `openai-stream` `convert-messages.ts` (per-provider args safety):** new
- pure `serializeToolArguments` — object→stringify; valid-string→parse+restringify;
- malformed-string→fallback `{ _malformed_arguments: <truncated 200> }`. Output ALWAYS
- `JSON.parse`s → provider stops 400ing on stored malformed args. +4 tests.
-- **Layer 2 (equiv) — `../claude` `provider-anthropic` `convert.ts`:** `safeJson` now
- returns a valid object fallback (`{ _malformed_arguments: s.slice(0,200) }`) on
- parse failure, not the raw string (`tool_use.input` must be an object for Anthropic).
- Exported for direct testing. +3 tests. (Separate repo, separate agent.)
-- **Wave 1+2 (parallel, disjoint):** conversation-store + openai-stream (arch-rewrite)
- + provider-anthropic (`../claude`). All in-lane; zero internal mocks; no contract/type
- change. Reports: `reports/conversation-store.md`, `reports/openai-stream.md`,
- `../claude/reports/provider-anthropic.md`.
-- [x] Verified: arch-rewrite `tsc -b` EXIT 0, biome clean, **1453 vitest** (was 1443);
- `../claude` `tsc -b` EXIT 0, 71 vitest, biome clean. Both pure-core units zero
- internal mocks.
-- [x] **LIVE-VERIFIED** (dev stack `bin/up` :24203): reproduced 77574596's REAL broken
- tail (the actual malformed-args tool-call + trailing error chunk) in the dev DB;
- `POST /chat` continued it cleanly (`text-delta:"OK"` → `done` reason `"stop"`, no
- 400) — the provider accepted the reconciled history (error stripped, args sanitized).
- The historical error chunk remains in storage by design (read-time repair only); no
- new error was appended. Cleaned up the test conversation after.
-
-## LSP — broken-server recovery + config source attribution (DONE)
-Handoff from an agent running in raylib-jamstack (configuring ruby-lsp under the
-installed Dispatch harness `/usr/bin/dispatch-server`): two issues found by
-decompiling the running binary. (Previous orchestrator session 77574596 did the
-investigation + Wave 0 + wrote the prompt; its chat broke mid-summon — resumed.)
-- **Issue 2 (blocker):** a failed LSP server was `broken` FOREVER — the manager's
- `broken` set (keyed `${serverId}:${root}`) was cleared ONLY in `shutdownAll()`, so a
- server that failed (bad env, missing binary, OR a since-fixed bad config) stayed
- `state:"error"` for the whole process. For an agent running *inside* dispatch the
- only recovery (server restart) kills its own session.
-- **Issue 1:** `.dispatch/lsp.json` (read first) silently shadowed `opencode.json`'s
- `lsp` key — a broken entry won with no warning, and the caller couldn't tell which
- config source a server came from (`status()` was its only visibility).
-- **Wave 0 (orchestrator, contracts):** additive `readonly configSource?: string` on
- `LspServerInfo` (`@dispatch/transport-contract` `0.20.0→0.21.0`) + a type-test
- assertion (8→9). tsc/biome/vitest clean.
-- **Wave 1 — `lsp` extension:** (a) broken-server now self-heals when its *resolved
- config changes* since it was marked broken (a config edit is a discrete event → no
- retry storm; bounded backoff for transient failures); (b) `configSource?` mirrored on
- `LspServerStatus` + populated in `status()` (`.dispatch/lsp.json` / `opencode.json` /
- `built-in`); (c) shadow warning via `host.logger` when both configs declare lsp; (d)
- spawn-failure `error` strings now name the config source. 6 required named tests +
- extras. Report: (agent cut off before writing `reports/lsp.md`; work independently
- verified — 50 lsp tests, tsc EXIT 0, biome clean).
-- **Wave 1 CR (transport-http):** the `GET /conversations/:id/lsp` handler mapped
- `LspServerStatus`→`LspServerInfo` field-by-field and DROPPED `configSource` (never
- reached the wire). Summoned the transport-http owner for the one-line conditional-spread
- pass-through (mirrors `error`, honors `exactOptionalPropertyTypes`) + a named pass-through
- test (present + undefined-omitted). Report: `reports/transport-http.md`.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1443 vitest** pass; all agents in-lane
- (only packages/lsp + transport-contract + transport-http touched; pre-existing
- uncommitted WIP in kernel/tool-shell left untouched). Zero internal mocks.
-- [x] **LIVE-VERIFIED** (dev stack `bin/up` on :24203, new code via `--watch`):
- (A) `configSource` reaches the wire — built-in TS server reports
- `configSource:"built-in"`, `state:"connected"` (Wave 0 + transport-http pass-through
- confirmed end-to-end); (B) a broken server (`.dispatch/lsp.json` → nonexistent binary)
- reports `state:"error"` + `configSource:".dispatch/lsp.json"` + a source-named error
- string (`broken-ts [from .dispatch/lsp.json]: Executable not found in $PATH: …`);
- (C) **recovery without restart** (the blocker) — same conversation/process went
- `error`→`connected` after the config was fixed (config change clears the broken key →
- re-spawn → connects); (D) no retry storm — repeated `status()` with no config change
- stays `error`; (E) shadow warning logged via `host.logger` (`extensionId:"lsp"`,
- level `warn`) when both `.dispatch/lsp.json` and `opencode.json` declare lsp.
-
-## Per-conversation model persistence (DONE)
-Bug: a chat's selected provider + model was NOT persisted per conversation.
-Opening the same chat in a new browser session defaulted to the server's
-default model rather than recalling the originally selected one.
-- **Wave 0 (orchestrator, contracts):** `@dispatch/transport-contract`
- `0.19.0→0.20.0` — additive `ModelResponse` + `SetModelRequest` types for
- `GET/PUT /conversations/:id/model`.
-- **Wave 1 — `conversation-store`:** `getModel`/`setModel` (`model:<id>` key,
- mirrors `getReasoningEffort`/`setReasoningEffort`); `forkHistory` copies model;
- empty string clears (idempotent). +13 tests.
-- **Wave 2 (parallel):** `session-orchestrator` (resolve model from persisted
- store when no per-turn override → `resolveModel`; persist the resolved model
- so it sticks; warm path parity; `resolveModelName` pure helper; +4 tests) +
- `transport-http` (`GET/PUT /conversations/:id/model` with validation +
- `parseModelBody` pure validator; +10 tests).
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1433 vitest** pass; all in-lane.
-
-## System-prompt stale on cwd change (DONE)
-Bug: the system-prompt service constructed the resolved prompt once on the first
-turn and reused it via `get()` on subsequent turns (cache-safe design). But the
-prompt is cwd-sensitive (`[file:AGENTS.md]`, `[prompt:cwd]` variables). When a
-conversation's cwd changed after the first turn, the cached prompt was stale —
-referenced files from the new cwd were not loaded.
-- **Wave 1 — `system-prompt`:** added `getWithMeta(conversationId)` returning
- `{ prompt, cwd }` — reads both `resolved:<id>` and a new `resolved-cwd:<id>`
- sibling key. `construct()` now also stores the cwd. All additive, no existing
- method signature/behavior changed. +5 tests.
-- **Wave 2 — `session-orchestrator`:** subsequent turns call `getWithMeta`,
- compare stored cwd vs `effectiveCwd ?? process.cwd()`, and `construct` if they
- differ (or if no stored prompt exists). Compaction path (always constructs)
- and warm path (no system prompt) unaffected. +1 test.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1411 vitest** pass; both in-lane.
-- No FE handoff needed (backend-only fix; no contract version bump).
-
-## Workspace tab issue — conversation.open drops workspaceId (DONE)
-Cross-repo additive fix: `conversation.open` / `conversation.statusChanged` WS
-broadcasts now carry the conversation's persisted workspace id, so a frontend
-opens/focuses a tab in the correct workspace instead of the viewer's current
-workspace (`activeWorkspaceId`). CLI `dispatch <model> --open --workspace my-ws`
-now opens only in `my-ws`.
-- **Wave 0 (orchestrator, contracts):** `@dispatch/transport-contract`
- `0.18.0→0.19.0` — additive `readonly workspaceId: string` on
- `ConversationOpenMessage` and `ConversationStatusChangedMessage`.
-- **Wave 1 (parallel):** `session-orchestrator` (add `workspaceId` to
- `ConversationOpenedPayload`/`ConversationStatusChangedPayload`; resolve from
- `conversationStore.getWorkspaceId` at all status-change emit sites) +
- `transport-ws` (thread `workspaceId` from hook payload into WS broadcasts) —
- disjoint packages.
-- **Wave 2:** `transport-http` — `POST /conversations/:id/open` now awaits
- `getWorkspaceId(conversationId)` and emits `conversationOpened` with it.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1405 vitest** green; all agents in-lane.
-- [x] **FE courier** to `29ae`: `frontend-workspace-open-handoff.md` — parse/use
- `workspaceId` from `conversation.open` and `conversation.statusChanged`;
- re-pin `@dispatch/transport-contract` `0.19.0`; re-mirror reference.md.
-
-## LSP cwd resolution — server-default fallthrough + workspace assignment (DONE)
-Bug: `GET /conversations/:id/lsp` called `getEffectiveCwd` directly, which falls through
-to `serverDefaultCwd` (`process.cwd()`) when no conversation cwd is set — the LSP
-connected on the wrong dir. Additionally, a new conversation's workspace isn't assigned
-until the first `chat.send`, so `getEffectiveCwd` resolved against `"default"` (not the
-intended workspace) when the FE set the cwd before the first turn.
-- **Wave 0 (orchestrator, contracts):** `@dispatch/transport-contract` `0.16.0→0.17.0` —
- additive `SetCwdRequest.workspaceId?: string` + updated `LspStatusResponse.cwd` comment
- ("resolved working directory the LSP connects on, or null when no cwd is set").
-- **Wave 1 — transport-http:** `GET /conversations/:id/lsp` now gates on `getCwd`
- (persisted) first — returns `{ cwd: null, servers: [] }` when no cwd set (LSP does NOT
- connect); only calls `getEffectiveCwd` + `lspService.status()` when a persisted cwd
- exists. `PUT /conversations/:id/cwd` now accepts optional `workspaceId` — validates
- with `isValidWorkspaceSlug`, then `ensureWorkspace` → `setWorkspaceId` → `setCwd`
- (assigns workspace before persisting cwd). 5 new tests + 1 assertion updated.
- Report: `reports/transport-http.md`.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1332 vitest** pass; agent in-lane.
-- [x] **FE courier** sent to FE agent `ffe3`: `frontend-lsp-cwd-workspace-handoff.md`
- — send `workspaceId` on `PUT /conversations/:id/cwd`; `GET /conversations/:id/lsp`
- now returns `cwd: null` + empty `servers` when no working dir is set.
-
-## Workspace cwd fallthrough + relative resolution (DONE)
-FE courier in: bug report + behavior change (`workspace defaultCwd` not used at turn start when
-a conversation has no explicit cwd; plus per-conversation cwd should be **relative to the workspace
-`defaultCwd`** unless absolute). Resolution is backend-owned (the FE omits `cwd` on `chat.send`).
-- **Scope:** single unit — `conversation-store` owns `getEffectiveCwd` (already consumed unchanged
- by `session-orchestrator` turn/warm + `transport-http` `GET /conversations/:id/lsp`), so no
- cross-package surface change and no fan-out. `GET /conversations/:id/cwd` uses `getCwd` (raw
- explicit cwd) — unchanged.
-- [x] **conversation-store** — added injectable `serverDefaultCwd` (default `process.cwd()`) to
- `createConversationStore`; rewrote `getEffectiveCwd` with the new algorithm: explicit conversation
- cwd null → `workspaceCwd ?? serverDefaultCwd` (bug fix: was returning null, skipping the workspace
- default); absolute (starts `/`) → overrides; relative → `path.resolve(workspaceCwd ??
- serverDefaultCwd, conversationCwd)`. Public signature `(conversationId) => Promise<string | null>`
- unchanged. 8 regression tests. Report: `reports/conversation-store-workspace-cwd.md`.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1289 vitest** pass; agent in-lane; zero internal mocks.
-
-## Per-turn cwd override not resolved relative to workspace (CURRENT — live-found)
-Live investigation (dev stack, tab 4ef4 in workspace `test` with `defaultCwd=/home/tradam/projects/
-dispatch`): `getEffectiveCwd` resolves a persisted relative cwd correctly (LSP endpoint + a chat
-**omitting** `cwd` both return `/home/tradam/projects/dispatch/arch-rewrite`). BUT a per-turn `cwd`
-sent on `chat.send` is used **as-is** by `session-orchestrator` (`cwd !== undefined ?
-Promise.resolve(cwd)`, orchestrator.ts:360), bypassing `getEffectiveCwd`. So raw `arch-rewrite`
-reaches `run_shell` → `resolve("arch-rewrite")` = `<process.cwd>/arch-rewrite` (nonexistent) → `pwd`
-broken; `./` → `resolve("./")` = `process.cwd()` (valid) → "works". The FE sends the CwdField value
-as a per-turn `cwd` (transport-ws threads it: router.ts:173 → extension.ts:277).
-- **Fix (2 waves):** add an optional `overrideCwd?: string` to `ConversationStore.getEffectiveCwd`
- (resolve the override if provided, else the persisted `getCwd` — same relative algorithm), then
- `session-orchestrator` passes the per-turn `cwd` (turn start + warm `opts.cwd`) as the override.
-- [x] **Wave 1 — conversation-store:** added `overrideCwd?` param + impl + tests.
-- [x] **Wave 2 — session-orchestrator:** pass per-turn cwd as override (turn start + warm) + tests.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1298 vitest** pass; both agents in-lane; zero
- internal mocks.
-- [x] **LIVE-VERIFIED** (dev stack, workspace `test` defaultCwd `/home/tradam/projects/dispatch`):
- a per-turn `cwd:"arch-rewrite"` on an existing conversation (assigned to `test`) → `pwd`
- returns `/home/tradam/projects/dispatch/arch-rewrite` (resolved, not broken). Both the
- omit-cwd path (Wave 0) and the per-turn-cwd path (Wave 2) confirmed working.
-- **Known edge case (pre-existing, not a regression):** a brand-NEW conversation's FIRST turn runs
- `getEffectiveCwd` *before* the workspace is assigned (orchestrator.ts assigns it later in the
- IIFE), so a relative per-turn cwd resolves against the "default" workspace (server default)
- instead of the intended one. Uncommon (CwdField typically set after the first message). Deferred.
-- **Note (separate pre-existing bug, not touched):** `DELETE /conversations/:id/cwd` returns
- `cwd:null` but does NOT clear the persisted cwd (transport-http app.ts:538 — the route is a stub).
-
-## Cwd edge cases — timing + DELETE stub (DONE)
-Two pre-existing bugs surfaced during live-verify of the relative-cwd fix:
-- **Edge 1 (timing):** a NEW conversation's first turn ran `getEffectiveCwd` BEFORE the workspace
- was assigned, so a relative per-turn cwd resolved against `"default"` (server default) not the
- intended workspace. **Fix:** session-orchestrator now assigns the workspace (for new
- conversations, detected via `getConversationMeta === null`) BEFORE resolving the effective cwd;
- removed the duplicate assignment site. 3 tests.
-- **Edge 2 (DELETE stub):** `DELETE /conversations/:id/cwd` returned `{cwd:null}` but did NOT
- clear the persisted cwd (no `clearCwd` on the store). **Fix:** conversation-store added
- `clearCwd(id)` (`storage.delete(cwdKey)`, idempotent) + tests; transport-http DELETE handler now
- `await clearCwd` for real.
-- [x] **Wave A (parallel):** conversation-store (clearCwd) + session-orchestrator (timing) — disjoint.
-- [x] **Wave B:** transport-http (DELETE handler uses clearCwd).
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1311 vitest** pass; all in-lane; zero internal mocks.
-- [x] **LIVE-VERIFIED** (dev stack): Edge 2 — PUT→GET(`/tmp/test`)→DELETE→GET(`null`) actually
- cleared. Edge 1 — NEW conversation, workspace `test`, per-turn `cwd:"arch-rewrite"` → `pwd`
- returns `/home/tradam/projects/dispatch/arch-rewrite` (resolved against workspace default, not
- broken).
-- [x] **FE courier handoff** written + sent: `frontend-cwd-resolution-handoff.md` couriered to FE
- orchestrator conversation `b18a` via `dispatch send b18a --queue` (turn started). Behavior-only
- — no `@dispatch/wire`/`transport-contract`/`ui-contract` version bumps; no FE contract change
- needed. Notes: `DELETE /conversations/:id/cwd` now actually clears; per-turn `cwd` on `chat.send`
- resolved relative to workspace `defaultCwd`; FE MAY omit `cwd` on `chat.send` (backend resolves
- persisted).
-
-Built and verified live (full-fidelity: every feature is a manifest-loaded
-extension through the host):
-- **kernel** — contracts (ABI), bus, `runTurn` turn loop, extension host.
-- **core extensions** — storage-sqlite, auth-apikey, provider-openai-compat
- (OpenCode Go), conversation-store, session-orchestrator, transport-http,
- credential-store; tool extensions `read_file` (files + directory listing), `run_shell`,
- `edit_file`, `write_file`.
-- **observability** — structured Logger/Span ABI + journal-sink → out-of-process
- collector → trace-store (`bun:sqlite`); host-bin supervises the collector;
- nested turn→step→{prompt, provider.request, ttft, decode} spans; D5 verbatim
- provider capture (self-redacted); `trace-replay` record/replay lib + fixtures.
-- **CLI** — one-shot HTTP client (`bun packages/cli/src/main.ts`); `GET /models`,
- `--cwd`, `--conversation`.
-- **web frontend** — SEPARATE repo `../frontend`. Slice 1 (surface system)
- shipped via `ui-contract` + `surface-registry` + `transport-ws` +
- `surface-loaded-extensions`. Slice 2 (browser chat) in progress there.
-
-## How to run
-```bash
-# .env auto-loads DISPATCH_API_KEY (do NOT re-export) and pins BACKEND_PORT (beats PORT).
-# Private probe instance: override the port + ISOLATE data paths (ORCHESTRATOR §8):
-BACKEND_PORT=4567 SURFACE_WS_PORT=4569 DISPATCH_DB=/tmp/opencode/probe/dispatch.db \
- DISPATCH_TRACE_DB=/tmp/opencode/probe/traces.db DISPATCH_JOURNAL=/tmp/opencode/probe/app.ndjson \
- bun packages/host-bin/src/main.ts # boots app + collector
-curl -s -X POST localhost:4567/chat -H 'content-type: application/json' \
- -d '{"conversationId":"c1","message":"Say hello in 3 words."}' # field = conversationId
-```
-Process cleanup uses the `[x]` bracket trick (ORCHESTRATOR §8) — leaked
-server/collector procs poison the next run's counts.
-
-**Two stacks:** `bin/up` = dev (live-reload backend, ports 24203/24205/24204).
-`../bin/up2` = a **stable, no-watch** second stack on **25203/25205/25204** with
-ISOLATED data (`./.dispatch-data/up2/`, `./.dispatch/journal/up2/`) — runs ALONGSIDE
-`bin/up`, edit backend code freely without restarting it; Ctrl-C stops only itself.
-Enabled by a new env knob **`SURFACE_WS_PORT`** → `surfaceWsPort` config
-(`host-bin/config.ts`; default 24205 when unset, so dev is unchanged).
-
-## Foundation (done — summarized; details in git)
-- **MVP + multi-turn:** curl → transport-http → session-orchestrator →
- host/registry → provider → OpenCode Go → AgentEvents → NDJSON;
- `conversationId` threads history.
-- **Post-MVP:** auth→provider seam; `read_file` tool (live tool-dispatch loop);
- `getHostAPI()` hygiene; `tabId → conversationId` rename.
-- **Observability Phase A/B:** the substrate + collector/store + supervision +
- replay fixtures (see bullet list above).
-- **CLI MVP:** credential-store + transport-contract + cli; model catalog; cwd
- threading; multi-turn.
-- **FE Slice 1:** the surface system across both repos (live WS probe verified).
-- **FE Slice 2 backend prereqs:** `@dispatch/wire` split; per-chunk `seq` cursor;
- read endpoint `GET /conversations/:id?sinceSeq=`; WS chat-deltas (transport-ws);
- turn-lifecycle events (`turn-start`/`done`/`turn-sealed`); step grouping
- (`stepId` on tool chunks/events); live stream metrics (`step-complete` +
- `usage`/`done` token/timing — "Pass 1"); CORS.
-
-## Metrics — token + timing (current milestone)
-- [x] **Pass 1 — live stream metrics** (done): `step-complete` event +
- `usage`(stepId) + `done`(durationMs + aggregate usage).
-- [x] **Observability spans** (done): turn & step span-close stamp all four
- `Usage` fields (added cacheRead/cacheWrite; normalized `usage_*` → `usage.*`).
-- [x] **Pass 2 — persisted replay metrics** (done, was deferred): `StepMetrics`/
- `TurnMetrics` wire types; conversation-store `appendMetrics`/`loadMetrics`
- (separate key space, turn-append order); session-orchestrator accumulates
- per-step+turn metrics from the event stream and persists after seal;
- transport-http `GET /conversations/:id/metrics` → `ConversationMetricsResponse`.
- `@dispatch/wire` + `@dispatch/transport-contract` → `0.4.0`. Commit `6db12ff`.
-- [x] **Live-verified end-to-end** (against flash): live `step-complete`/`done`
- metrics ↔ persisted `GET /conversations/:id/metrics` byte-match (aggregate +
- per-step `stepId` + ttft/decode/genTotal + durationMs); journal turn/step spans
- carry dotted `usage.*` incl. `usage.cacheReadTokens` (the #2 fix).
-- [x] **FE courier handoff** written: `frontend-metrics-pass2-handoff.md` (in
- this repo; user couriers to `../frontend`; ORCHESTRATOR §7).
-
-## dedup / storage growth (DONE)
-Design `notes/observability-design.md` §12. User-gated calls: extend existing
-pipeline (no new ext); scope = **de-dup + retention/rotation** (D9 roll-ups
-deferred); dedup = **content-addressed bodies** (body-hash, NOT fingerprint-gated).
-- [x] **Wave 1 — `trace-store`**: content-addressed `bodies` table (SHA-256),
- at-rest gzip (>1 KiB), `prune(policy)` (age + drop-oldest byte-cap + orphan GC) /
- `RetentionPolicy` / `PruneSummary` / `DEFAULT_RETENTION` (7d/256MiB); reads
- transparent.
-- [x] **Wave 2 — `observability-collector`**: pure `shouldPrune` cadence helper;
- `main.ts` calls `store.prune(DEFAULT_RETENTION)` on a coarse cadence
- (`--prune-interval-ms`, default 60s; host-bin-overridable), log-and-continue on
- error.
-- [x] Glossary: added content-addressed body, trace retention, prefix fingerprint,
- warm vs real.
-- [x] **Migration bug** (found by live boot, fixed): Wave 1 created the
- `idx_records_bodyHash` index BEFORE running `migrateOldBodies`, so opening a
- pre-existing OLD-schema `traces.db` crashed the collector
- (`no such column: bodyHash`, crash-looped). Fix = reorder migration before the
- index + 3 regression tests that seed a real old-schema DB. bun 106→109.
-- Tests: bun 89→109. typecheck/biome clean. **Live-verified** against a real
- old-schema `traces.db`: 0 crashes, collector stays up, schema migrates
- (bodyHash + content-addressed bodies), real-data dedup (318 body refs → 270
- stored bodies), prune cadence fires cleanly (14× `prune completed`). Optional
- follow-up: host-bin env-override for the retention policy.
-
-## Standard tools — fs + shell (DONE)
-User-gated calls: **one tool per extension** (matches `tool-read-file` precedent); tools are
-**standard** tier (a turn completes with `tools:[]`, §2.6/§2.8). **Zero ABI change** — the
-`ToolContract`/`ToolExecuteContext` already carry `signal`/`onOutput`/`cwd`/`log`.
-- **Wave 1 (parallel, disjoint pkgs, kernel-only dep) — all green:**
- - [x] `tool-read-file` — EXTENDED `read_file` to list directory contents (sorted, `/`-suffixed
- subdirs; files unchanged). 41 tests.
- - [x] `tool-shell` (new) — `run_shell`: foreground, streamed via `ctx.onOutput`, `ctx.signal`
- cancel, `ctx.cwd`, timeout + output cap, `concurrencySafe:false`; injected `spawn`. 31 tests.
- - [x] `tool-edit-file` (new) — `edit_file`: `oldString`/`newString`/`replaceAll`; errors on
- absent/non-unique/identical; workdir-contained; `concurrencySafe:false`. 38 tests.
- - [x] `tool-write-file` (new) — `write_file`: explicit `overwrite` flag (absent+unset→create;
- exists+unset→error; exists+true→overwrite; absent+true→error); no parent auto-create. 33 tests.
-- **Wave 2 (done):** orchestrator added 3 root tsconfig refs + `bun install`; host-bin owner
- registered the 3 new extensions in `CORE_EXTENSIONS` (same pattern as `read_file`).
-- **Live-verified:** clean boot (`Dispatch booted`, collector up, no activation/capability-gate
- error — the new `shell` capability is accepted); full-graph `tsc -b` EXIT 0, biome clean.
-- **Recovery notes (scar tissue):** `tool-write-file` first returned plan-only (§5a) → re-summoned
- with "IMPLEMENT NOW". `tool-edit-file` hung vitest at collection — `computeReplacement` infinite-
- looped on empty `oldString` (`"".indexOf("") === 0`, index never advances) invoked at a test's
- `describe` scope; fixed with an early empty-string guard + validation. One agent deleted
- `ORCHESTRATOR.md` out-of-lane → caught by post-wave `git status`, restored from git.
-- Deferred (not selected): `glob`, `grep`/`search_code`, background shells.
-
-## Skill system + load_skill tool (DONE)
-User-gated calls: skills list lives in the **`load_skill` tool definition** (NOT the system prompt),
-refreshed **per new turn** (cache-stable across steps), **live file read** on execute. One `skills`
-standard extension (loader + filter + tool). Skill = md in `.skills/`; discovered from `~/.skills` +
-`<cwd>/.skills` (cwd shadows home); name = filename w/o `.md`. Format: line1 = summary,
-line2 = `---`, body = line3+; on load the first two lines are stripped; malformed (no `---`) =
-no summary but still loadable. Glossary: added `skill`, `skill summary`, `tools filter`.
-- **Mechanism — the per-turn `tools` filter chain** (first concrete use of the §3.2 context-assembly
- chain; reusable for persona/agents later):
- - [x] **kernel** — exposed `HostAPI.applyFilters` (delegates to the bus's existing `applyFilters`).
- - [x] **session-orchestrator** — defines+exports `toolsFilter`/`ToolAssembly`; applies it ONCE per
- turn (injected `applyToolsFilter` dep) before `runTurn`, threading `cwd`+`conversationId`.
- - [x] **skills** (new ext, `dependsOn session-orchestrator`) — pure parse/merge/render +
- `load_skill` tool (live read, strips first two lines, path-contained) + a `toolsFilter` filter
- that rewrites `load_skill`'s description + `name` enum with the per-cwd catalog. 42 tests.
- - [x] **host-bin** — registered `skills` in `CORE_EXTENSIONS`.
- - [x] **Fan-out (§5.3):** `applyFilters` was a required `HostAPI` addition → broke one consumer
- (transport-http `server.bun.test.ts` inline HostAPI stub) → fixed by its owner.
-- **Live-verified:** clean boot (`skills` activates, filter registered, no crash); full-graph
- `tsc -b` EXIT 0, biome clean. (End-to-end load_skill via a real LLM turn not yet exercised —
- unit/integration tests cover the filter rewrite + live read.)
-
-## Cache warming (core DONE; control surface PARTIAL)
-User-gated calls: target the external **Claude** provider (`../claude` provider-anthropic, loaded via
-`DISPATCH_EXTERNAL_EXTENSIONS`); warm-assembly lives in **session-orchestrator** (`warm()` reuses the
-real turn's assembly → byte-identical prefix, provider-agnostic); **surface system** for controls;
-**per-conversation** controls; interval default 4 min, free value. Old-code invariants honored
-(primary-model/full-prefix via reuse; refuse mid-turn; never persist/emit; in-flight invalidation;
-arm-on-settle/cancel-on-start; `pct = round(clamp(cacheRead/input,0,1)*100)`).
-- **Mechanism (2nd use of bus hooks; first event-hook emit):**
- - [x] **kernel** — exposed `HostAPI.emit` (delegates to bus.emit), counterpart of `on`.
- - [x] **session-orchestrator** — `turnStarted`/`turnSettled` event hooks (carry conversationId/cwd/
- modelName) emitted per turn; `warm()` service (`cacheWarmHandle`) reusing assembly, refusing
- mid-turn, never persisting/emitting; returns Usage.
- - [x] **cache-warming** (new ext) — per-conversation timers (arm/cancel/in-flight token),
- calls `warm()`, computes `lastPct`, persists `{enabled,intervalMs}` (default on/240s) in
- host.storage; registers a controls Surface. 19 tests.
- - [x] **host-bin** — registered cache-warming; **transport-http** HostAPI stub fixed for `emit`.
-- **Manual trigger endpoint:** `POST /chat/warm {conversationId, model?, cwd?}` → `WarmResponse`
- `{inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,cachePct}` (409 if generating). Powers a
- FE "warm now" button + fast tests. Types in `@dispatch/transport-contract`; route in transport-http.
-- **LIVE-VERIFIED against Claude haiku:** automatic timer warm → journal `warm complete pct:100`;
- manual `POST /chat/warm` → `cacheReadTokens:6799, cachePct:100` (100% hit), HTTP 200. The external
- `../claude` provider-anthropic is loaded via `bin/up` (`DISPATCH_EXTERNAL_EXTENSIONS`).
-- **Cache-metric fix + retention metric:** `provider-anthropic` (in `../claude`, commit `0e9d118`)
- now reports `Usage.inputTokens` as the TOTAL prompt (was the uncached remainder → the cache rate
- inflated/clamped to 100% on Claude). So `cacheRead/inputTokens` is now the true rate (live: a turn
- adding new content reads 61%, not 100%). Added **`expectedCacheRate`** = `cacheRead/(cacheRead+
- cacheWrite)` (retention/health, ~100% when warm, 0% when the cache expired) to `WarmResponse` +
- `POST /chat/warm` + the cache-warming surface (a "cache retention" stat). Live-verified: warm
- within TTL → 100%; warm after >5 min idle → 0% (cache expired). FE handoff updated with both
- metrics + the cross-turn real-turn `expectedCache = cacheRead_N/(cacheRead_{N-1}+cacheWrite_{N-1})`.
-- **Surface framework extended (DONE):** added `NumberField` to `ui-contract` + per-conversation
- surface scoping (optional `conversationId` on subscribe/unsubscribe/invoke + surface/update; new
- `SurfaceContext` on `SurfaceProvider.getSpec/invoke`; transport-ws keys subscriptions by
- `(surfaceId, conversationId)` and tags updates). cache-warming now serves a PER-CONVERSATION
- surface: `Toggle`(enabled) · `Number`(interval, seconds, `cache-warming/set-interval`) ·
- `Stat`(last cache %). All backward-compatible (global surfaces like `surface-loaded-extensions`
- unchanged). **FE courier:** `frontend-cache-warming-handoff.md` (this repo) — the web must render
- the `number` field kind + send/handle `conversationId` on the surface WS protocol.
-
-## Cache warming — FE CR-3 (DONE)
-FE asked (frontend `backend-handoff-cache-warming-timer.md`): expose next/last-warm timestamps +
-make a manual warm reset the timer/refresh the surface. Done via an **inversion** (commit `bfbad3a`):
-session-orchestrator `warm()` (the single chokepoint for manual `/chat/warm` AND the auto timer) emits
-a `warmCompleted` bus event; cache-warming subscribes and does all post-warm handling — so manual
-warms re-arm the timer + push a surface update with **no transport-http change** (core can't depend on
-the standard cache-warming ext). Added `nextWarmAt`/`lastWarmAt` state + a `custom`
-`rendererId:"cache-warming-timer"` surface field (no ui-contract bump). Caught + fixed a wiring bug
-(`createWarmService` missed the `emit` dep → `deps.emit?.` silently no-oped; made it required).
-Live-verified vs claude haiku (manual warm logs `warm complete` ~2s after the turn, not the 4-min
-timer). FE handoff updated. (FE CR-1 table + CR-2 catalog `scope` flag still open, not requested.)
-
-## LSP integration + per-conversation CWD (DONE)
-Design: `notes/lsp-design.md`. FE courier: `frontend-lsp-cwd-handoff.md`. Decisions
-(locked): **single `lsp` extension**; **hand-rolled pure JSON-RPC codec** (zero dep,
-injected-stream tested); **diagnostics-on-write deferred** (on-demand `lsp` tool
-only); **cwd persisted in `conversation-store`**; config = **built-in TypeScript +
-`<cwd>/.dispatch/lsp.json` + `<cwd>/opencode.json` `lsp` fallback** (Roblox works
-with its existing config). Glossary: added LSP, language server, diagnostics,
-workspace root, working directory.
-- **The bug we fixed** (opencode root cause, confirmed): opencode's
- `client/registerCapability` ignores all but `textDocument/diagnostic`, so
- `workspace/didChangeWatchedFiles` registrations are dropped + no real fs watcher
- → stale `sourcemap.json` → "Unknown require" mid-session. Fix = honor the
- registration + real fs watcher + forward `didChangeWatchedFiles` + auto-spawn
- `rojo sourcemap --watch` sidecar when `luau-lsp.sourcemap.autogenerate`. Covered
- by a regression test in `packages/lsp/src/client.test.ts`.
-- **`lsp` extension** (new, bundled core): hand-rolled LSP client (framing + rpc +
- watched-files + diagnostics + config + root + tool + manager), zero external deps.
- Lazy-spawn one server per `(serverID, root)`; config resolved **per cwd**;
- `lspServiceHandle.status(cwd)` lazy-connects + reports state; `deactivate` kills
- all child procs (host-bin shutdown now calls `host.deactivate()`).
-- **CWD:** `conversation-store.getCwd/setCwd`; `session-orchestrator` defaults a
- turn's cwd from the store; endpoints `GET`/`PUT /conversations/:id/cwd` +
- `GET /conversations/:id/lsp` in transport-http; wire types in
- `@dispatch/transport-contract` (→ `0.5.0`).
-- **LIVE-VERIFIED:** this repo (`typescript`) → `connected`; `/home/tradam/projects/
- roblox` (`luau-lsp`) → `connected` (via the project's own `opencode.json` + rojo
- sidecar); cwd PUT/GET round-trip 200. Op note: LSP binaries must be on the server
- process PATH (`~/.local/bin` daemon-PATH caveat for `typescript-language-server`).
-- **Recovery (scar tissue):** the `lsp` agent stalled on the final stretch (1 hung
- test + ~40 biome `!`/dot-key findings) → at the user's request the orchestrator
- finished it directly; also fixed a real design bug the agent missed: the manager
- read config statically instead of per-cwd (would have broken Roblox).
-
-## Context size — current context-window usage (DONE)
-User-gated decisions: term = **context size** (current usage; reserve "context window" for the
-model's max LIMIT, a later feature); definition = the turn's **FINAL step `inputTokens +
-outputTokens`** (NOT the aggregate `usage`, which sums per-step prompts and overcounts a
-multi-step turn); delivery = a backend-computed field on BOTH the live `done` event and the
-persisted `TurnMetrics`.
-- [x] **Contract (orchestrator):** optional `contextSize?: number` added to `TurnDoneEvent` +
- `TurnMetrics` in `@dispatch/wire` (`0.4.0→0.5.0`); `@dispatch/transport-contract`
- `0.5.0→0.6.0` (re-exports both — no other change). Glossary: added **context size**.
-- [x] **Wave (parallel, disjoint pkgs):**
- - [x] **kernel** — `run-turn.ts` tracks the last step's `Usage`; `doneEvent()` stamps
- `done.contextSize = lastStep.input + lastStep.output` (omitted when no usage). +3 tests.
- - [x] **session-orchestrator** — `metrics.ts build()` stamps `TurnMetrics.contextSize` from
- the final per-step metrics (same definition; equals the live value). +5 tests.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, 881 vitest pass; both owners stayed in-lane.
- `conversation-store` (JSON passthrough) + `transport-http` (forwards/serves) unchanged.
-- [x] **LIVE-VERIFIED against flash** (`deepseek-v4-flash`): turn 1 → live `done.contextSize`
- 1255 == persisted `turns[-1].contextSize` 1255 == final-step `1206 in + 49 out` (NOT the
- aggregate); turn 2 (same conversation) → 1286 (grew cumulatively), live == persisted. Both
- carriers agree; "current" = latest turn's value.
-- [x] **FE courier handoff:** `frontend-context-size-handoff.md` (user couriers to
- `../frontend`).
-
-## Turn continuity — detached turns + multi-client live view (DONE)
-Design: `notes/turn-continuity-design.md`. FE courier: `frontend-turn-continuity-handoff.md`.
-Problem (code-traced): a turn's lifetime was bound to the WS connection — `transport-ws` aborted
-the in-flight turn on socket close, so a backgrounded/reloaded mobile browser killed generation.
-Principle enforced: **the FE is only a control interface; the AI runs independent of it**, and
-**multiple clients may watch the same conversation** (multi-device handoff).
-- **Decisions (locked):** broadcast hub lives in the CORE (`session-orchestrator`), not a
- transport; additive `SessionOrchestrator` handle (keep `handleMessage`); persist-at-seal kept,
- per-step R1 deferred; late-join served by an in-memory in-flight buffer; subscribers persist
- per-conversation independent of turns; no concurrent-send arbitration; no explicit stop op.
-- **Contract (orchestrator):** `@dispatch/transport-contract` `0.6.0→0.7.0` — additive WS ops
- `chat.subscribe`/`chat.unsubscribe` on `WsClientMessage` (events still arrive as `chat.delta`).
-- **Wave 1 — `session-orchestrator`:** detached per-conversation turn ownership + broadcast;
- `startTurn`/`subscribe`/`isActive` added to the handle; `handleMessage` → convenience wrapper
- (dropped `signal`). **Two-map model** (`subscribers` persistent + `activeTurns` buffer) — the
- fix for the live-found bug where pre-turn subscribers were dropped. 63 tests.
-- **Wave 2 (parallel) — `transport-ws`** (fan-out: per-connection chat-subscription map;
- `chat.send` auto-subscribes sender + `startTurn`; new ops in pure `router.ts`; `close` drops
- subs but NEVER aborts a turn; removed the turn `AbortController`) + **`transport-http`** (only
- test fakes updated for the 3 new methods; runtime unchanged). host-bin untouched.
-- **LIVE-VERIFIED against flash** (2-client WS test, `/tmp/ws_multi.ts`): (S1) two clients both
- stream a turn; closing the SENDER mid-turn → the other keeps receiving through `done` and the
- turn persists (1197 chars) — AI kept going independent of the interface; (S2) a client joining
- mid-turn gets `turn-start` replayed + the rest live. `RESULT OVERALL: OK`.
-- **Recovery (scar tissue):** first Wave-1 impl stored listeners INSIDE the per-turn hub and
- `startTurn` made a fresh empty-listener hub → every pre-turn subscriber dropped; live test got
- zero deltas though the turn ran+persisted. Caught by live-verify (unit test had subscribed
- AFTER start, masking it). Fixed via the persistent-subscribers / per-turn-buffer split.
-
-## Turn continuity — CR-3: user prompt on the event stream (DONE)
-FE bug (multi-client): a pure watcher (subscribed, not the sender) couldn't see the USER prompt until
-seal — the user message was passed to the provider + persisted only at seal, never on the turn's
-outward stream/buffer. FE courier: `frontend-cr3-user-message-handoff.md`.
-- **Contract:** `@dispatch/wire` `0.5.0→0.6.0` — additive `TurnInputEvent`
- `{ type:"user-message"; conversationId; turnId; text }` on the `AgentEvent` union (kernel barrels
- re-export it). `@dispatch/transport-contract` `0.7.0→0.8.0` (re-export only). Widening broke NO
- exhaustive switch (typecheck clean) — zero consumer fan-out.
-- **session-orchestrator:** `emitToHub({type:"user-message",…})` as the FIRST event of `runTurnDetached`
- (before `runTurn`) → buffered + broadcast to all subscribers (live + late-join); HTTP path covered via
- `handleMessage`'s buffer replay. Persistence + metrics unchanged. +3 tests; 3 Wave-1 tests updated
- (user-message now precedes turn-start).
-- **LIVE-VERIFIED vs flash:** a watcher that never sent receives `user-message` (correct text) as its
- FIRST `chat.delta`, before `turn-sealed`, then the streaming reply. `RESULT: OK`.
-- **Process note:** implemented directly by the orchestrator as a one-off (user-approved at the
- time). SUPERSEDED — the user has since confirmed the ORCHESTRATOR.md model governs: the
- orchestrator summons owner-agents and does not write feature code itself.
-
-## Cache warming — FE CR-4 lifecycle + CR-1 extensions table + CR-2 catalog scope (DONE)
-FE courier in: `../frontend/backend-handoff-cache-warming.md` (+ CR-1/CR-2 from their living
-`backend-handoff.md`). Courier out: `frontend-cache-warming-lifecycle-handoff.md`. Full report:
-`reports/cr4-cache-warming-lifecycle.md`.
-- **CR-4a:** warming defaults OFF (opt-in per conversation) — `parseSettings` + `DEFAULT_STATE`;
- re-enabling now restores the persisted interval. Known gap (pre-existing, fail-safe): no boot
- hydration of persisted opt-in across server restarts.
-- **CR-4b:** post-warm surface updates now carry the FUTURE `nextWarmAt` (re-arm BEFORE notify);
- `turnSettled`/`turnStarted` also push (fresh schedule after seal / `null` while generating).
-- **CR-4c:** new `POST /conversations/:id/close` (tab close ≠ disconnect): aborts the in-flight
- turn via a per-turn `AbortController` → kernel `runTurn` `signal` (partial persist + normal seal,
- `done.reason:"aborted"`), and emits new typed hook `conversationClosed` → cache-warming disables
- sync + persists OFF. Disconnect/`chat.unsubscribe` semantics unchanged.
-- **CR-4d:** no change — initial `surface` echo already at HEAD (FE probed a stale up2 boot).
-- **CR-1:** loaded-extensions emits count stat + ONE `custom`/`rendererId:"table"` field
- (`TablePayload` exported); columns Name|Version|Trust|Activation, all trust tiers.
-- **CR-2:** `SurfaceCatalogEntry.scope?: "global"|"conversation"` (`ui-contract` `0.1.0→0.2.0`);
- set on both surfaces. `transport-contract` `0.8.0→0.9.0` (additive `CloseConversationResponse`).
-- 907 tests pass (+13 new); typecheck + biome clean. **LIVE-VERIFIED vs `bin/up`:** default-off,
- 2 automatic warms @5s each pushing future `nextWarmAt`, mid-turn close → `abortedTurn:true` +
- `done.reason:"aborted"` + warming disabled, catalog scopes + table field present, echo present.
-
-## History windowing — FE CR-5 (DONE)
-FE courier in: `../frontend/backend-handoff-chat-limit.md` (+ living `backend-handoff.md` §2
-CR-5). Courier out: `frontend-history-windowing-handoff.md`. User-gated call: ask #3 shipped as
-the INVARIANT option (no new field) — seq is contractually **1-based, monotonic, gap-free**; FE
-derives `hasOlder` from `chunks[0].seq > 1`.
-- **Wave 0 (orchestrator, contracts):** `limit`/`beforeSeq` query-param semantics + validation +
- `latestSeq` windowed-read caveat documented on `ConversationHistoryResponse`
- (`@dispatch/transport-contract` `0.9.0→0.10.0`); 1-based seq guarantee codified on
- `StoredChunk` (`@dispatch/wire` `0.6.0→0.6.1`, doc-only).
-- **Wave 1 — `conversation-store`:** additive `loadSince(id, sinceSeq?, window?: { beforeSeq?,
- limit? })` — selection `sinceSeq < seq < beforeSeq`, newest-`limit` window, result stays
- ascending; garbage-in treated as absent (transport validates upstream). +8 tests.
-- **Wave 2 — `transport-http`:** parses + validates the params (positive integers; malformed/
- zero/negative → 400 `{ error }`, store never called with an invalid window); two-arg call
- shape preserved when no params (regression-guarded). +20 tests.
-- 935 vitest + 112 bun tests, typecheck + biome clean. **LIVE-VERIFIED** (isolated boot, real
- flash turns): firstSeq=1; `limit=2`→`[5,6]` ascending w/ correct `latestSeq`; `limit=9999`→
- full log; `beforeSeq=3`→`[1,2]`; `beforeSeq=3&limit=1`→`[2]`; `limit=0`/`beforeSeq=0`/
- `limit=abc`→400×3. `RESULT: OK` ×6.
-- **Scar tissue (process):** (1) probing with a PRIVATE boot was overkill — the windowing checks
- are read-only GETs and the dev stack was running; prefer probing `bin/up`/`up2` or asking the
- user (ORCHESTRATOR §8 updated). (2) The §8 boot recipe was stale (`DISPATCH_API_KEY_OPENCODE1`
- doesn't exist; an empty re-export OVERRIDES `.env` → "No providers registered"; `.env`'s
- `BACKEND_PORT` beats `PORT`; un-isolated data paths spawn a duplicate collector on the dev
- DB) — recipe fixed in §8 + above. (3) Violated the bracket trick once (`pkill -f 'cr5-data'`
- self-matched → killed parent shell, timeout-with-no-output); the existing §8 rule stands.
-
-## Reasoning effort (current milestone)
-User-gated calls: canonical term **reasoning effort** (GLOSSARY); ladder `low|medium|high|xhigh|max`
-(Anthropic-driven, includes xhigh/max); scope = **(c)** persisted per-conversation + per-turn
-`ChatRequest.reasoningEffort` override; resolution default **`high`**; provider picks sensible
-budget_tokens; `../claude` orchestrated DIRECTLY (mode A); CLI `--effort` now.
-- [x] **Wave 0 (orchestrator, contracts):** `ReasoningEffort` in `@dispatch/wire` (`0.6.1→0.7.0`);
- `ProviderStreamOptions.reasoningEffort` (kernel contract; runtime untouched — providerOpts is
- forwarded verbatim); `ChatRequest.reasoningEffort` + `ReasoningEffortResponse`/
- `SetReasoningEffortRequest` GET/PUT types (`@dispatch/transport-contract` `0.10.0→0.11.0`);
- glossary entry. typecheck + biome clean.
-- [x] **Wave 1 (parallel ×3, disjoint):** `conversation-store` get/setReasoningEffort (own key
- space, mirrors cwd; +12 tests); `provider-anthropic` (../claude commit `c0835a4`, mode A summon
- with `--dir ../claude`, contract excerpt INLINED per the cross-`--dir` hang rule) —
- `REASONING_EFFORT_BUDGETS` 4096/10240/16384/32768/65536, raises max_tokens above budget, strips
- temperature when thinking on, absent → byte-stable body (+12 tests); `cli` `--effort` flag,
- parse-validated, body key omitted when unset (+8 tests).
-- [x] **Wave 2:** `session-orchestrator` — exported pure `resolveReasoningEffort` (override →
- stored → `"high"`), additive `StartTurnInput.reasoningEffort`, providerOpts always stamped,
- **warm() parity** (same resolved effort as a real turn — prompt-cache safe), own fakes fixed
- (+9 tests).
-- [x] **Wave 3 (parallel ×2):** `transport-http` — `/chat` validation (400 names valid levels,
- orchestrator never sees bad input), threads to startTurn, GET/PUT
- `/conversations/:id/reasoning-effort` mirroring cwd endpoints, own fakes fixed; `transport-ws` —
- `chat.send` threading + validation (+3 tests).
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **993 vitest + 189 bun** green; all agents in-lane.
- Commits: arch-rewrite `35197ed` (contracts) + `020e051` (impl); ../claude `c0835a4`.
-- [x] Live-verified vs claude (thinking deltas streamed at xhigh; persisted PUT honored next turn).
-- [x] FE courier handoff written: `frontend-reasoning-effort-handoff.md` (user couriers to
- `../frontend`): ChatRequest/chat.send field + GET/PUT endpoints + ladder + default-`high`
- semantics + cache note.
-
-## Message queue + steering injection (DONE)
-Design: this file's roadmap item 3 (now implemented). User-gated calls: a **separate
-`message-queue` standard extension** (dependsOn `surface-registry`) owns the queue STATE +
-a per-conversation `custom` surface; the **session-orchestrator** owns delivery (drain →
-inject → carry) + emits the `steering` event (it owns the chat hub — no `chatEmit` service
-needed); the **kernel** gets a generic `drainSteering` callback. Glossary: added
-**message queue**, **steering**, **queued message**. Enqueue when idle **starts a turn**
-(user choice; `chat.queue` degrades to `chat.send`). Steering text rendered live via a new
-additive `steering` `AgentEvent`; queue state via the surface (NOT the chat stream).
-- **Wave 0 (orchestrator, contracts):** `RunTurnInput.drainSteering?: () => readonly
- ChatMessage[]` (kernel contract — generic, kernel stays pure); `QueuedMessage` +
- `QueuePayload` + `TurnSteeringEvent` (type `"steering"`, additive to `AgentEvent`) in
- `@dispatch/wire` (`0.7.0→0.8.0`); `POST /conversations/:id/queue` + WS `chat.queue` op +
- `QueueRequest`/`QueueResponse` in `@dispatch/transport-contract` (`0.11.0→0.12.0`). typecheck
- clean except the expected transport-ws exhaustive-switch fan-out (fixed in Wave 3).
-- **Wave 1 (parallel ×2, disjoint):** `kernel` runtime — calls `drainSteering` at the
- tool-result boundary only when continuing to a next step (gated; no drain on max-steps),
- +6 pure tests (65 total); `message-queue` (NEW ext) — pure queue core (enqueue/getQueue/
- drain/combine) + `MessageQueueService`/`messageQueueHandle` + per-conversation `custom`
- surface (`rendererId:"message-queue"`, `QueuePayload`), 12 tests. (The message-queue agent
- DIED mid-task after writing all src+tests but before verifying/reporting; orchestrator
- recovered by running `bun install` + root tsconfig ref + verifying directly — tsc/vitest/
- biome clean, 12 tests pass; no hand-fixing of impl.)
-- **Wave 2:** `session-orchestrator` — added `enqueue` facade (idle→`startTurn`,
- active→queue.enqueue) + `resolveQueue?` dep (self-wired lazily in `activate` via
- `host.getService(messageQueueHandle)` — host-bin does NOT wire it) + `drainSteering` wrapper
- (drain → emit `steering` → return one combined user `ChatMessage`) + post-seal carry
- (non-empty queue → new turn), +8 tests (85 total). `message-queue` is an OPTIONAL dep
- (feature degrades off if absent).
-- **Wave 3 (parallel ×3):** `host-bin` — registered `message-queue` in `CORE_EXTENSIONS`
- (+dep+ref), 28 tests; `transport-http` — `POST /conversations/:id/queue` route + validation,
- 145 tests; `transport-ws` — `chat.queue` op + fixed the Wave-0 exhaustive-switch fan-out,
- 29 vitest + 20 bun.
-- Verified: `tsc -b` EXIT 0, biome clean (280 files), **1043 vitest + 199 transport bun** pass;
- all agents in-lane. **Boot smoke:** private instance boots clean with `message-queue`
- registered (no activation crash).
-- [x] FE courier handoff written: `frontend-message-queue-handoff.md` (user couriers to
- `../frontend`): surface (`rendererId:"message-queue"`), `chat.queue` WS op, `steering`
- event, HTTP `POST /queue`, auto-start-when-idle, carry semantics, version bumps.
-
-## Umans AI Coding Plan provider (DONE)
-User-gated calls: a new **`provider-umans`** standard extension wrapping the Umans
-OpenAI-compatible backend (`https://api.code.umans.ai/v1`). Built via the **full-refactor
-path**: first extract a generic `@dispatch/openai-stream` library from
-`provider-openai-compat`, then build `provider-umans` on top. Self-contained (reads
-`UMANS_API_KEY` from env directly — no `auth-apikey` dep).
-- **Wave 1 — `@dispatch/openai-stream` lib (NEW package):** extracted the generic OpenAI
- functions (convert-messages, convert-tools, parse-sse, listModels, stream, provider)
- from `provider-openai-compat` into a pure library package. `createOpenAICompatProvider`
- parameterized: `id: string` (was hardcoded `"openai-compat"`) + `transformBody?: (body,
- opts) => Record<string,unknown>` hook (for provider-specific body fields). Refactored
- `provider-openai-compat` to import from the lib (thin extension.ts, backward-compat
- re-exports, manifest unchanged, byte-identical behavior). Full tsc EXIT 0, 66 vitest,
- biome clean. Report: `reports/provider-umans-wave1-openai-stream.md`.
-- **Wave 2 — `provider-umans` (NEW ext):** imports `createOpenAICompatProvider` from the
- lib; registers provider id `"umans"`; `transformBody` maps Dispatch `reasoningEffort`
- (`low|medium|high|xhigh|max`) → Umans `reasoning_effort` (`none|low|medium|high`,
- capping `xhigh`/`max`→`high`); dynamic `listModels` (GET /v1/models); default model
- `umans-coder` (env `UMANS_MODEL` or config `provider.umans.model`); baseURL env
- `UMANS_BASE_URL`; absent key → warn + skip registration (graceful). Pure core:
- `mapReasoningEffort` + `resolveUmansConfig` (factored out for direct unit testing).
- 12 tests. Report: `reports/provider-umans.md`.
-- **Wave 3 — host-bin wiring:** registered `provider-umans` in `CORE_EXTENSIONS` + added
- `@dispatch/provider-umans` dep + root tsconfig ref. No credential-store entry needed
- (self-contained — reads env directly, doesn't go through `auth-apikey`). 28 host-bin
- tests.
-- Verified: full-graph `tsc -b` EXIT 0, biome clean (293 files), **1059 vitest** pass.
- **Boot smoke:** without `UMANS_API_KEY` → `"provider-umans: no UMANS_API_KEY. Provider
- not registered."` (graceful skip); with `UMANS_API_KEY=sk-test` → `"provider-umans:
- registered (model=umans-coder)"`.
-- [x] **LIVE-VERIFIED against the real Umans API:** the dev stack (umans-glm-5.2) called
- `web_search` (Firecrawl) in a real turn — first live Umans API call, clean response.
-
-## web_search tool — Firecrawl (DONE)
-Standard tool extension `tool-web-search` backed by a self-hosted Firecrawl instance
-(`http://100.102.55.49:31329/v1`, Tailscale, no API key). One tool `web_search` with 4
-modes: search, scrape, crawl (polls status URL), map — mirroring the proven opencode tool.
-Pure core: `validateArgs` (discriminated union by mode) + `format*` functions + `truncateOutput`.
-Injected edge: `FirecrawlClient` (injectable `fetchFn` + `sleep` + `now`), `AbortSignal.any`
-for per-request timeout + caller cancellation. `concurrencySafe: true`, `capabilities: { network: true }`.
-38 tests. Report: `reports/tool-web-search.md`.
-- **LIVE-VERIFIED:** the dev stack (umans-glm-5.2) called `web_search` → Firecrawl returned
- real results (Paris, France) — first live Umans API call too.
-
-## todo tool — per-conversation task list + surface (DONE)
-Standard tool extension with a single `todo_write` tool (opencode `todowrite` pattern:
-full-list replace, returns JSON, no business-rule enforcement — the description guides
-the model). Per-conversation in-memory state (`Map<conversationId, TodoItem[]>`). Per-
-conversation surface (`rendererId: "todo"`, `scope: "conversation"`) via subscriber-notify
-(message-queue pattern). `concurrencySafe: false` (mutates shared state).
-- **Wave 0 (orchestrator, kernel contract):** added `conversationId?: string` to
- `ToolExecuteContext` (additive, backward-compatible). Wired in `dispatch.ts` — the
- kernel already had `conversationId` as a parameter, just wasn't passing it through to
- the tool context. 170 kernel tests pass.
-- **Wave 1 (todo extension):** pure core (`validateTodos` — shape only; `getTodos`/
- `setTodos`/`clearTodos` — fresh array copies; `buildTodoSpec`; `formatTodoResult` →
- `JSON.stringify`). Shell: `createTodoWriteTool({ state, notify })` + surface provider.
- 26 tests. Report: `reports/todo.md`.
-- **Wave 2 (host-bin wiring):** registered `todo` in `CORE_EXTENSIONS` + dep + root tsconfig
- ref. 28 host-bin tests.
-- Verified: full-graph `tsc -b` EXIT 0, biome clean (314 files), **1123 vitest** pass.
- **Boot smoke:** `"todo: registered"` + activated.
-- [x] Live-verified (model uses `todo_write` in a real turn).
-
-## youtube_transcript tool (DONE)
-Standard tool extension `tool-youtube-transcript` backed by a self-hosted transcriber
-service (`http://100.102.55.49:41090`, Tailscale, no API key). One tool
-`youtube_transcript` — takes a YouTube URL, fetches the transcript (completed → full
-text + timestamped segments; queued/processing → position + ETA + `.youtube_subtitles_pending`
-retry convention; failed → error). Pure core: `validateUrl` + `format*` functions +
-`truncateOutput`. Injected edge: `TranscriptClient` (injectable `fetchFn`, `AbortSignal.any`
-for cancellation). `concurrencySafe: true`, `capabilities: { network: true }`. 30 tests.
-Report: `reports/tool-youtube-transcript.md`.
-
-## CLI — cross-client messaging + open tab (DONE)
-Roadmap items 2 + 4. The CLI can now list conversations, read the last AI message
-(blocking), send messages (blocking or `--queue`), and signal the frontend to open a
-conversation tab. Short-ID prefix resolution (4+ chars → full ID via `GET /conversations?q=`).
-- **Wave 0 (orchestrator, contracts):** `ConversationMeta` in `@dispatch/wire`
- (`0.8.0→0.9.0`); `ConversationListResponse`, `LastMessageResponse`,
- `OpenConversationResponse`, `SetTitleRequest`, `TitleResponse`, WS
- `conversation.open` in `@dispatch/transport-contract` (`0.12.0→0.13.0`);
- `listConversations()`/`getConversationMeta()`/`setConversationTitle()` on
- `ConversationStore`; new routes declared in transport-http manifest;
- `conversationOpened` hook in session-orchestrator.
-- **Wave 1 (conversation-store):** metadata tracking (createdAt on first write,
- lastActivityAt on every append, title from first user message truncated 80 chars);
- `conv-index` key tracks all conversation IDs; `extractTitle` pure helper. 21 new
- tests (81 total).
-- **Wave 2 (parallel, transport-http + transport-ws):** `GET /conversations` (list
- with `?q=` prefix filter), `GET /conversations/:id/last` (blocks until turn settles
- via subscribe-then-checkIsActive, returns last assistant text via pure
- `extractLastAssistantText`), `POST /conversations/:id/open` (emits
- `conversationOpened` hook), `PUT /conversations/:id/title`; `emit` threaded from
- `host.emit` → `createApp`. transport-ws subscribes to `conversationOpened` +
- broadcasts `ConversationOpenMessage` to all connected WS clients. 21+2 new tests.
-- **Wave 3 (CLI):** `dispatch list` (table: short ID + title + activity),
- `dispatch read <id>` (blocking, prints last AI message), `dispatch send <id> --text`
- (blocking by default; `--queue` for non-blocking enqueue; `--open` signals FE).
- Short-ID resolution (4+ chars → prefix search; 32+ chars = full UUID). 48 new
- tests (108 total).
-- Verified: full-graph `tsc -b` EXIT 0, biome clean (327 files), **1240 vitest** pass.
- **Boot smoke + endpoint smoke:** `GET /conversations` → `[]`, `GET /conversations/:id/last`
- → `{content:""}`, `POST /conversations/:id/open` → `{conversationId}`.
-- [x] Live-verified end-to-end (CLI → real conversation → FE tab open).
-
-## Workspaces (DONE)
-Cross-repo design ask from `../frontend` (`backend-handoff-workspaces.md`).
-Outbound courier: `frontend-workspaces-handoff.md` (final shapes + Q1–Q8).
-- **Boundary decision:** workspaces live inside `conversation-store` (metadata +
- cwd persistence owner); no new extension. Single owner-agent for all workspace
- storage + service methods.
-- **Versions:** `@dispatch/wire` `0.11.0→0.12.0`, `@dispatch/transport-contract`
- `0.15.0→0.16.0`, `@dispatch/ui-contract` unchanged. Kernel re-exports
- `Workspace`/`WorkspaceEntry`.
-- **Key decisions:** `DELETE /workspaces/:id` closes all conversations (status→
- "closed") + reassigns to "default" + deletes workspace; auto-create workspace on
- turn start if missing; `PUT /workspaces/:id` create-on-miss with optional
- `title`/`defaultCwd`; `DELETE /conversations/:id/cwd` to clear explicit cwd;
- `GET /conversations/:id/lsp` roots at effective cwd; WS lifecycle push deferred.
-- **Waves:**
- - **Wave 0 (orchestrator):** contracts (wire `0.12.0` + transport-contract
- `0.16.0` + kernel re-exports). tsc + biome clean.
- - **Wave 1 (conversation-store):** workspace persistence + service methods
- (`getWorkspace`, `ensureWorkspace`, `setWorkspaceTitle`, `setWorkspaceDefaultCwd`,
- `deleteWorkspace`, `listWorkspaces`, `getWorkspaceId`, `setWorkspaceId`,
- `getEffectiveCwd`, `isValidWorkspaceSlug`); `listConversations` filter;
- `forkHistory`/`replaceHistory` preserve `workspaceId`. 111 bun tests. CRs
- (kernel re-exports, `bun install`) resolved by orchestrator.
- - **Wave 2 (session-orchestrator):** `workspaceId` on `StartTurnInput`/
- `EnqueueInput`; effective cwd resolution (`getCwd` → `getEffectiveCwd`); auto-
- create workspace on turn start; warm parity. 93 vitest (+8).
- - **Wave 3 (parallel):** `transport-http` (workspace routes, `workspaceId`
- threading, `?workspaceId=` filter, `DELETE /conversations/:id/cwd`, effective
- cwd for LSP, slug validation; 166 tests), `transport-ws` (`workspaceId` on
- `chat.send`/`chat.queue`; 32 tests), `cli` (`--workspace`/`-w` flag; 123 tests).
- - FE handoff sent to agent 4091 via `dispatch send --queue` (non-blocking).
-- Verified: full-graph `tsc -b` EXIT 0, biome clean (328 files), **1283 vitest +
- 199 transport bun** pass (1 pre-existing `tool-shell` failure unrelated).
-- **LIVE-VERIFIED** against dev stack (`bin/up`): 11/11 workspace checks pass —
- create-on-miss, rename, set default-cwd, invalid-slug 400, unknown 404, delete-
- default 409, chat with workspaceId stamps conversation, workspace filter, cwd
- inheritance (null = inheriting), delete cascade (closedCount:1, workspace→404).
-- `dist/` rebuilt for FE (wire + transport-contract + kernel .d.ts contain Workspace
- types). FE agent 4091 notified twice (handoff + dist-ready).
-
-## Open items
-- **`prefix.fingerprint` / `warm|real` cache-bust attributes (deferred):** decoupled
- from dedup by the content-addressed decision; also gated on cache-warming being
- built (not yet) so `warm|real` can't be honestly stamped. Later cache-bust-debug
- milestone (`notes/observability-design.md` §3.1, §12).
-- **D9 analytics roll-ups (deferred):** rollup table shape + `GROUP BY` indexes +
- retention asymmetry + periodic rollup job (`notes/observability-design.md` §2 D9,
- §12). The scheduler mechanism (`host.scheduler.register`) already exists.
-- **D8 `prompt.assembly` segments:** deferred-by-design (await the context-filter
- chain).
-- **In-memory state persistence (message queue + todo list):** both the message
- queue and the todo list are in-memory only (`Map<conversationId, …>` in the
- extension's `activate`). Neither persists across server restarts. If persistence
- is needed later, both would write through `host.storage` (the conversation-store
- pattern: separate key space per feature, append/write per conversation).
-
-## Roadmap
-1. **Web frontend** (in progress, SEPARATE repo `../frontend`; Svelte +
- DaisyUI, same methodology). Slice 2 = browser chat MVP consuming the
- wire/transport-contract + metrics. Cross-repo contract changes are couriered
- via the user (ORCHESTRATOR §7); `lsp references` does not span repos.
-2. **Message queue — close-with-queued-messages (deferred product decision):**
- if a client closes a conversation (`POST /conversations/:id/close`) while the
- queue is non-empty, the carry currently still fires (starts a new turn on the
- closed conversation). Decide: does closing discard pending steering, or honor
- it? If "discard," gate the carry on `finishReason !== "aborted"` in
- session-orchestrator (one-line). No FE action either way.
-3. **FE: consume `GET /conversations/:id/status` for crash-recovery re-sync.**
- Backend endpoint shipped: returns `{ conversationId, isActive, status }` where
- `isActive` is the orchestrator's in-memory truth and `status` is the persisted
- lifecycle status. On reconnect (WS re-establish or page reload), the FE should
- call this for any tab it believes is "generating"; if `isActive: false`,
- override the local spinner to idle regardless of the persisted `status`
- (defense-in-depth against status drift the boot-sweep didn't catch).
-
-(Done and dropped from the list: CLI; dedup / storage growth; message queue +
-steering injection; CLI open-tab handoff; `todo` tool; `web_search` tool; tab
-persistence across devices; conversation compacting; live-verify steering flow.)
-
-## Stop generation must abort a hanging tool + not brick the conversation (DONE)
-FE courier in: "Stop generation doesn't abort a hanging tool call." When the user clicks Stop during
-a tool that hangs (e.g. `run_shell` with a blocking/grandchild-holding process), the turn never
-sealed → the FE spinner spun forever AND the conversation was bricked (next `chat.send` rejected as
-`"already-active"` because `activeTurns` was never cleared).
-- **Root cause:** the kernel's `executeToolCall` awaited `tool.execute(...)` with **no race against
- the abort signal** — a tool that ignored `ctx.signal` (or blocked on something it couldn't
- interrupt) blocked `drain` → `runTurn` never returned → session-orchestrator's `finally` (which
- clears `activeTurns`) never ran. (The `/stop` endpoint, `stopTurn`, and the `finally` cleanup were
- already correct — they just needed `runTurn` to return.) Secondary: `realSpawn` resolved on
- `child.on("close")` (waits for stdio) and killed only the immediate child, so a grandchild holding
- the pipes could stall the spawn promise + leak.
-- [x] **kernel** — `executeToolCall` now **races** `tool.execute` against `signal` via `Promise.race`;
- on abort it **resolves** (not rejects) `{ content: "Aborted", isError: true }` so the step completes
- normally → kernel's existing `signal.aborted → finishReason "aborted"` path runs → turn seals
- cleanly (`done` + `turn-sealed`) → `finally` clears `activeTurns` → **conversation freed, next
- message accepted**. Late rejections from the orphaned tool promise are swallowed. 11 tests incl.
- the durability test (hanging tool `new Promise(() => {})` + abort → `runTurn` returns
- `finishReason "aborted"`, doesn't hang). Report: `reports/kernel-abort-race.md`.
-- [x] **tool-shell** — `realSpawn` spawns `detached: true` (own process group); on abort **and**
- timeout kills the **group** (`process.kill(-pgid, "SIGKILL")`) AND resolves immediately (no
- `close`-dependency) so a grandchild holding the pipes can't stall the spawn or leak. 4 tests
- (grandchild abort, grandchild timeout, normal-completion stdout capture, simple abort). Report:
- `reports/tool-shell-process-group-kill.md`.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1326 vitest** pass; both in-lane; kernel zero
- internal mocks.
-- [x] **Live-verified** (fresh `bin/up`): start a hanging tool (`run_shell` sleep/grandchild),
- Stop, then send a NEW message → it must be ACCEPTED (conversation not bricked) and the spinner
- clears.
-
-## System prompt builder — template-based system context (DONE)
-Design: `notes/system-prompt-design.md`. FE courier: `frontend-system-prompt-handoff.md`.
-Problem: no system prompt was sent to the provider for regular turns (the messages array
-started with the user message; `providerOpts.systemPrompt` was never set). This adds a
-template-based system prompt builder with variable placeholders (`[type:name]`) and
-conditionals (`[if]`/`[else]`/`[endif]`).
-- **Cache constraint (critical):** the system prompt is constructed ONCE (first turn of
- a new conversation) and persisted. Reused on all subsequent turns (no reconstruction —
- cache-safe). Reconstructed only on **compaction** (fresh variable resolution + compaction
- instructions appended).
-- **Variable types:** `system:time/date/os/hostname`, `prompt:cwd/model/conversation_id`,
- `git:branch/status`, `file:<path>` (dynamic — any path).
-- **Wave 0 (orchestrator, contracts):** `@dispatch/transport-contract` `0.17.0→0.18.0` —
- `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`,
- `SystemPromptVariablesResponse`.
-- **Wave 1 — `system-prompt` (NEW ext):** pure parser (29 tests) + variable resolver
- (injected adapters, 12 tests) + catalog (3 tests) + service handle (`construct` +
- `get` + `getTemplate` + `setTemplate`, 8 tests). 52 tests total. Default template:
- persona + AGENTS.md if exists + cwd.
-- **Wave 2 (parallel):** `session-orchestrator` (wire service: construct on first turn,
- get on subsequent, construct+append on compaction; 12 tests) + `transport-http`
- (GET/PUT `/system-prompt`, GET `/system-prompt/variables`; 6 tests).
-- **Wave 3 — host-bin:** registered `system-prompt` in `CORE_EXTENSIONS`.
-- [x] Verified: `tsc -b` EXIT 0, biome clean, **1396 vitest** pass.
-- [x] Live-verified (boot smoke: extension activates, `GET /system-prompt` returns default
- template, `GET /system-prompt/variables` returns catalog).
-- [x] **FE courier** sent to FE agent `ffe3`: `frontend-system-prompt-handoff.md`.