summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/package-agent.md6
-rw-r--r--AGENTS.md4
-rw-r--r--ORCHESTRATOR.md32
-rw-r--r--README.md39
-rwxr-xr-xbin/up54
-rw-r--r--bun.lock52
-rw-r--r--notes/frontend-design.md524
-rw-r--r--notes/restructure-plan.md6
-rw-r--r--package.json3
-rw-r--r--packages/host-bin/package.json5
-rw-r--r--packages/host-bin/src/main.ts7
-rw-r--r--packages/host-bin/tsconfig.json11
-rw-r--r--packages/kernel/package.json5
-rw-r--r--packages/kernel/src/contracts/conversation.ts101
-rw-r--r--packages/kernel/src/contracts/events.ts133
-rw-r--r--packages/kernel/src/contracts/extension.ts3
-rw-r--r--packages/kernel/src/contracts/provider.ts12
-rw-r--r--packages/kernel/src/host/host.test.ts111
-rw-r--r--packages/kernel/src/host/host.ts7
-rw-r--r--packages/kernel/tsconfig.json3
-rw-r--r--packages/surface-loaded-extensions/package.json13
-rw-r--r--packages/surface-loaded-extensions/src/extension.ts43
-rw-r--r--packages/surface-loaded-extensions/src/index.ts2
-rw-r--r--packages/surface-loaded-extensions/src/spec.test.ts94
-rw-r--r--packages/surface-loaded-extensions/src/spec.ts25
-rw-r--r--packages/surface-loaded-extensions/tsconfig.json10
-rw-r--r--packages/surface-registry/package.json12
-rw-r--r--packages/surface-registry/src/extension.ts23
-rw-r--r--packages/surface-registry/src/index.ts4
-rw-r--r--packages/surface-registry/src/registry.test.ts122
-rw-r--r--packages/surface-registry/src/registry.ts80
-rw-r--r--packages/surface-registry/src/service.ts4
-rw-r--r--packages/surface-registry/tsconfig.json6
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts2
-rw-r--r--packages/transport-contract/tsconfig.json2
-rw-r--r--packages/transport-ws/package.json13
-rw-r--r--packages/transport-ws/src/extension.ts161
-rw-r--r--packages/transport-ws/src/index.ts4
-rw-r--r--packages/transport-ws/src/manifest.ts13
-rw-r--r--packages/transport-ws/src/router.test.ts203
-rw-r--r--packages/transport-ws/src/router.ts116
-rw-r--r--packages/transport-ws/src/server.bun.test.ts195
-rw-r--r--packages/transport-ws/tsconfig.json10
-rw-r--r--packages/ui-contract/package.json8
-rw-r--r--packages/ui-contract/src/index.ts198
-rw-r--r--packages/ui-contract/tsconfig.json5
-rw-r--r--packages/wire/package.json8
-rw-r--r--packages/wire/src/index.ts221
-rw-r--r--packages/wire/tsconfig.json5
-rw-r--r--tasks.md45
-rw-r--r--tsconfig.json5
-rw-r--r--vitest.config.ts20
53 files changed, 2479 insertions, 313 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md
index 782a543..9a769d3 100644
--- a/.dispatch/package-agent.md
+++ b/.dispatch/package-agent.md
@@ -31,6 +31,12 @@ it, test it, and write a report — nothing else. If no single package is named,
exports (and manifest, if any). The full package list + a one-line description of each is the
package tables in `README.md`. Don't read unrelated packages' internals.
+## Headless read boundary (you run non-interactively)
+You run HEADLESS: a Read of any file OUTSIDE this repo triggers a permission prompt that
+CANNOT be answered → the run HANGS until aborted. Read ONLY within this repo. If you believe
+you need a file outside it, do NOT attempt the read — STOP and write the need in your report,
+then end.
+
## Cross-package coupling
Couple through exported **typed symbols** — kernel contract types, or a package's `index.ts`
exports. A package that is a **library** is itself a sanctioned shared surface (others import
diff --git a/AGENTS.md b/AGENTS.md
index 174abc7..f173a7e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,9 @@
## What this project is
A **minimal kernel + extensions** agent runtime. The kernel runs ONE agent turn
and hosts extensions. Every feature is an extension. Tiers: **kernel → core →
-standard**. Backend only for now (no frontend).
+standard**. The web frontend is a SEPARATE repo (`../dispatch-web`), built to
+the same methodology and consuming the backend's typed contracts (see
+`notes/frontend-design.md`).
## Stack
Bun + TypeScript (strict, project references via `tsc -b`). Biome for
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
index 73afe06..128823e 100644
--- a/ORCHESTRATOR.md
+++ b/ORCHESTRATOR.md
@@ -113,6 +113,16 @@ log into context as a hard failure.
in `tasks.md`.
**GOTCHAS (learned the hard way):**
+- **Headless cross-`--dir` read = HANG.** An agent's Read of any file OUTSIDE its
+ `--dir` triggers an interactive permission prompt that CANNOT be answered headlessly
+ → the run wedges until aborted. This bites CROSS-REPO: a `file:` dep symlink (e.g.
+ `dispatch-web/node_modules/@dispatch/ui-contract` → the sibling repo) resolves OUTSIDE
+ `--dir`, so an agent reading the dep's source hangs. Fixes: (a) keep everything the
+ agent must READ inside `--dir` — ship an **in-repo reference snapshot** of a cross-repo
+ contract and FORBID reading `node_modules/@dispatch/*`; OR (b) set `--dir` to a parent
+ containing all needed paths — but then the repo's `AGENTS.md` won't auto-load (you lose
+ the constitution). The briefs now tell agents: never read outside your scope — if you
+ think you need to, REPORT it and STOP, never attempt the read.
- `-f/--file` is an ARRAY flag and greedily eats your trailing message as another
filename → "File not found". **Inline with `"$(cat prompts/X.md)"` instead.**
- A quick smoke test works: `opencode run -m opencode-go/mimo-v2.5-pro "Reply with
@@ -150,6 +160,9 @@ Keep it scoped (P6): state only the project-specific, non-inferable task — the
*(pending — authored with the observability substrate, see
`notes/observability-design.md` §9; keystone: each extension self-redacts its OWN
secrets in its OWN code — NO shared redaction helper).*
+- **Frontend units** are summoned from the SEPARATE `../dispatch-web` repo using ITS
+ OWN harness (`package-agent.md` + `frontend-*.md` rules) + ITS OWN scoping map — NOT
+ these backend rules. See that repo's `ORCHESTRATOR.md`.
---
@@ -290,7 +303,10 @@ git status --short # confirm the agent stayed in its lane (no out-of-scope edit
packages/
kernel/ contracts (ABI), bus, runtime (runTurn), host
+ wire/ types-only wire ABI (AgentEvent + conversation model + Usage); kernel +
+ transport-contract re-export it so clients consume the wire w/o the kernel runtime
transport-contract/ types-only HTTP API contract (CLI + future web + server share it)
+ ui-contract/ types-only surface ABI (frontend-agnostic; web + CLI render it)
storage-sqlite/ conversation-store/ auth-apikey/ provider-openai-compat/
credential-store/ named credentials + model catalog (resolve / listCatalog)
session-orchestrator/ transport-http/ (core extensions)
@@ -304,6 +320,13 @@ The genesis commit deleted all prior source; we rebuilt from scratch. The OLD
project lives at `/home/tradam/projects/dispatch/dispatch-source` (reference only
— do not edit).
+The **web frontend is a SEPARATE repo** at `/home/tradam/projects/dispatch/dispatch-web`
+(own git, own harness — its own `AGENTS.md`/`ORCHESTRATOR.md`/`GLOSSARY.md`/`.dispatch/`).
+It consumes `packages/ui-contract` + the wire types as a pinned `file:` dependency.
+`lsp references` does NOT span the two repos, so cross-repo contract changes are
+**couriered via the user** (see the FE `ORCHESTRATOR.md` §5). Design + plan:
+`notes/frontend-design.md`. Do NOT edit the FE repo from here.
+
---
## 8. Current status & how to run
@@ -342,6 +365,15 @@ literal pattern `[h]ost-bin` does not match itself. ALWAYS clean up the backgrou
counts (this is precisely what made a correct supervisor look like it spawned 3
collectors and left 2 behind).
+**Live boot-probe in ONE command WILL hit the tool timeout — that is NOT failure (scar tissue).**
+A single bash command that boots the app (even detached via `setsid … & disown`), sleeps, runs a
+probe, then kills it will still run to the tool's timeout: the tool waits on the spawned
+server/collector session. The probe already ran — **read the probe's printed `RESULT: OK/FAIL`
+line as the signal**, ignore the timeout, then run a SEPARATE `pkill` (bracket-trick) + `ps`
+cleanup command (it returns immediately and confirms no leaks). Don't try to make the boot+probe
+command "return cleanly" — it won't. (For a frontend-agnostic surface, the probe is a tiny
+`bun` WebSocket client that asserts `catalog → subscribe → surface`.)
+
**Next suggested work** (post-MVP, see `tasks.md` "Open items"): wire
auth→provider properly (auth-apikey is currently vestigial), then add the first
TOOL extension to exercise the dispatch loop (turns currently run with `tools:
diff --git a/README.md b/README.md
index 24b9e93..7c01369 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
A **minimal kernel + extensions** agent runtime. The kernel runs one agent turn and hosts
extensions; every feature is an extension. Backend + a line-oriented CLI; a web frontend lives
-in a separate repo and talks to the same HTTP API.
+in a separate repo and talks to the same typed contracts over HTTP + a surface WebSocket.
- **Stack:** Bun + TypeScript (strict, project references), Biome, Vitest, SQLite (`bun:sqlite`),
the Vercel AI SDK for providers.
@@ -54,6 +54,9 @@ bun install
Dispatch listening on http://localhost:24203
```
+ It also serves a **surface WebSocket** on `:24205` (the `transport-ws` extension) — the channel
+ the web frontend uses to discover and render backend-declared *surfaces*.
+
3. **Smoke-test it:**
```sh
@@ -127,6 +130,34 @@ bun run dispatch -- opencode/deepseek-v4-flash --conversation <id> --text "and i
---
+## Web frontend (dispatch-web)
+
+The web UI is a **separate repo** at `../dispatch-web` (Svelte 5 + Vite), built to the same
+methodology and consuming the backend's typed contracts. As of slice 1 it renders the backend's
+**surface system** (e.g. the live "Loaded Extensions" surface); chat UI is a later slice.
+
+It needs **this server running** — it connects to the surface WebSocket on `:24205`. To run both:
+
+```sh
+# terminal 1 — backend (this repo)
+bun run dev # HTTP :24203 + surface WS :24205
+
+# terminal 2 — frontend (sibling repo)
+cd ../dispatch-web
+bun install # links @dispatch/ui-contract via a file: dep to this repo
+bun run dev # Vite dev server on http://localhost:24204
+```
+
+Then open **http://localhost:24204**. See `../dispatch-web/README.md` for full setup, including
+visiting over a LAN / Tailscale.
+
+**Or run both at once with live reload:** `bin/up` (also `bun run dev:all`) starts the backend
+(`bun --watch`) and the frontend (Vite HMR) together and **Ctrl-C stops both** — including the
+backend's observability collector. (The backend reloads via a full process restart; the frontend
+hot-reloads in place.)
+
+---
+
## Packages
### Kernel & extensions
@@ -145,6 +176,9 @@ topologically at activation). Every extension also depends implicitly on the ker
| **session-orchestrator** | core | Drives one turn end-to-end: load history → resolve provider/model/tools → call `runTurn` → persist. | conversation-store, credential-store |
| **transport-http** | core | Hono HTTP transport exposing `POST /chat` (NDJSON event stream) and `GET /models` (the catalog). | credential-store, session-orchestrator |
| **tool-read-file** | standard | A `read_file` tool with offset/limit pagination and two-layer workdir containment, honoring the per-turn `cwd`. | — |
+| **surface-registry** | standard | In-process registry where extensions contribute UI **surfaces** (frontend-agnostic data); exposes a typed `surfaceRegistryHandle` service. | — |
+| **transport-ws** | standard | WebSocket transport (`:24205`) serving the surface catalog + per-surface subscribe / update / invoke to clients. | surface-registry |
+| **surface-loaded-extensions** | standard | Contributes the live "Loaded Extensions" surface (a `stat` per activated extension) — the first real surface. | surface-registry |
### Supporting packages (not extensions)
@@ -153,8 +187,9 @@ The **Depends on** column is each package's `@dispatch/*` workspace dependencies
| Package | Description | Depends on |
|---|---|---|
| **transport-contract** | Types-only description of the HTTP API (`ChatRequest`, `ModelsResponse`, `AgentEvent`) shared by the server and every client. | kernel |
+| **ui-contract** | Types-only, **frontend-agnostic** vocabulary for backend-declared **surfaces** (`SurfaceSpec`, field kinds, the surface WS protocol) — shared by the backend and any client (web, CLI). | — |
| **cli** | The bundled one-shot terminal client documented above. | transport-contract |
-| **host-bin** | The composition root: loads config, activates all extensions through the host, serves HTTP, and supervises the observability collector. | kernel, all 8 extensions, journal-sink |
+| **host-bin** | The composition root: loads config, activates all extensions through the host, serves HTTP, and supervises the observability collector. | kernel, all extensions, journal-sink |
| **journal-sink** | Bootstrap `LogSink` that appends structured logs/spans to an NDJSON journal (rotation, fail-safe). | kernel |
| **observability-collector** | Out-of-process binary that tails the journal and inserts records into the trace store (idempotent, at-least-once). | kernel, trace-store |
| **trace-store** | `bun:sqlite` store for trace records/bodies, plus a `trace` CLI to render a turn's timeline. | kernel |
diff --git a/bin/up b/bin/up
new file mode 100755
index 0000000..8d529bc
--- /dev/null
+++ b/bin/up
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# bin/up — run the Dispatch backend + web frontend together, both live-reloading.
+# Ctrl-C stops BOTH cleanly (including the backend's spawned observability collector).
+#
+# backend (this repo) bun --watch → HTTP :24203 + surface WS :24205 (FULL restart on change)
+# frontend (../dispatch-web) vite → http://localhost:24204 (HMR, in-place)
+#
+# Assumes dispatch-web is a sibling of this repo. Run: bin/up (or: bun run dev:all)
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../arch-rewrite/bin
+BACKEND="$(cd "$HERE/.." && pwd)" # backend repo root
+FRONTEND="$(cd "$HERE/../.." && pwd)/dispatch-web" # sibling repo
+
+if [ ! -d "$FRONTEND" ]; then
+ echo "up: frontend repo not found at $FRONTEND" >&2
+ echo "up: check out 'dispatch-web' beside this repo and retry." >&2
+ exit 1
+fi
+
+cleanup() {
+ echo
+ echo "[up] stopping backend + frontend..."
+ # Each child runs in its OWN process group (setsid) → signal the whole subtree.
+ [ -n "${BACK_PG:-}" ] && kill -TERM "-$BACK_PG" 2>/dev/null
+ [ -n "${FRONT_PG:-}" ] && kill -TERM "-$FRONT_PG" 2>/dev/null
+ sleep 1
+ # Safety net for the backend's collector child + any straggler (bracket trick:
+ # the literal pattern '[h]ost-bin' can't match the pkill command line itself).
+ pkill -9 -f '[h]ost-bin/src/main' 2>/dev/null
+ pkill -9 -f '[o]bservability-collector/src/main' 2>/dev/null
+ [ -n "${BACK_PG:-}" ] && kill -KILL "-$BACK_PG" 2>/dev/null
+ [ -n "${FRONT_PG:-}" ] && kill -KILL "-$FRONT_PG" 2>/dev/null
+ echo "[up] stopped."
+ return 0
+}
+trap cleanup EXIT
+trap 'exit 130' INT TERM
+
+echo "[up] backend → http://localhost:24203 (surface WS :24205) [bun --watch — restarts on change]"
+echo "[up] frontend → http://localhost:24204 [vite HMR]"
+echo "[up] Ctrl-C to stop both."
+echo
+
+setsid bash -c "cd '$BACKEND' && exec bun --watch packages/host-bin/src/main.ts" \
+ > >(sed -u 's/^/[backend] /') 2>&1 &
+BACK_PG=$!
+
+setsid bash -c "cd '$FRONTEND' && exec bun run dev" \
+ > >(sed -u 's/^/[frontend] /') 2>&1 &
+FRONT_PG=$!
+
+wait
diff --git a/bun.lock b/bun.lock
index 67f6277..13a9e6c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,8 +51,11 @@
"@dispatch/provider-openai-compat": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/storage-sqlite": "workspace:*",
+ "@dispatch/surface-loaded-extensions": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
"@dispatch/tool-read-file": "workspace:*",
"@dispatch/transport-http": "workspace:*",
+ "@dispatch/transport-ws": "workspace:*",
},
},
"packages/journal-sink": {
@@ -65,6 +68,9 @@
"packages/kernel": {
"name": "@dispatch/kernel",
"version": "0.0.0",
+ "dependencies": {
+ "@dispatch/wire": "workspace:*",
+ },
},
"packages/observability-collector": {
"name": "@dispatch/observability-collector",
@@ -98,6 +104,23 @@
"@dispatch/kernel": "workspace:*",
},
},
+ "packages/surface-loaded-extensions": {
+ "name": "@dispatch/surface-loaded-extensions",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*",
+ },
+ },
+ "packages/surface-registry": {
+ "name": "@dispatch/surface-registry",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*",
+ },
+ },
"packages/tool-read-file": {
"name": "@dispatch/tool-read-file",
"version": "0.0.0",
@@ -120,7 +143,7 @@
"name": "@dispatch/transport-contract",
"version": "0.0.0",
"dependencies": {
- "@dispatch/kernel": "workspace:*",
+ "@dispatch/wire": "workspace:*",
},
},
"packages/transport-http": {
@@ -134,6 +157,23 @@
"hono": "^4.0.0",
},
},
+ "packages/transport-ws": {
+ "name": "@dispatch/transport-ws",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*",
+ },
+ },
+ "packages/ui-contract": {
+ "name": "@dispatch/ui-contract",
+ "version": "0.0.0",
+ },
+ "packages/wire": {
+ "name": "@dispatch/wire",
+ "version": "0.0.0",
+ },
},
"packages": {
"@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
@@ -176,6 +216,10 @@
"@dispatch/storage-sqlite": ["@dispatch/storage-sqlite@workspace:packages/storage-sqlite"],
+ "@dispatch/surface-loaded-extensions": ["@dispatch/surface-loaded-extensions@workspace:packages/surface-loaded-extensions"],
+
+ "@dispatch/surface-registry": ["@dispatch/surface-registry@workspace:packages/surface-registry"],
+
"@dispatch/tool-read-file": ["@dispatch/tool-read-file@workspace:packages/tool-read-file"],
"@dispatch/trace-replay": ["@dispatch/trace-replay@workspace:packages/trace-replay"],
@@ -186,6 +230,12 @@
"@dispatch/transport-http": ["@dispatch/transport-http@workspace:packages/transport-http"],
+ "@dispatch/transport-ws": ["@dispatch/transport-ws@workspace:packages/transport-ws"],
+
+ "@dispatch/ui-contract": ["@dispatch/ui-contract@workspace:packages/ui-contract"],
+
+ "@dispatch/wire": ["@dispatch/wire@workspace:packages/wire"],
+
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
diff --git a/notes/frontend-design.md b/notes/frontend-design.md
index b9145e5..16cb41f 100644
--- a/notes/frontend-design.md
+++ b/notes/frontend-design.md
@@ -1,79 +1,455 @@
-# Frontend MVP — Design Scratch
+# Frontend — Design (decisions LOCKED)
-> **Status:** IDEATION / scratch. NOT decided, NOT building yet. This is the HOME for the
-> "carefully plan the frontend" pass the user asked for (per ORCHESTRATOR "write up before
-> pivoting"). Promote settled parts into `notes/restructure-plan.md` + `GLOSSARY.md` +
-> harness files when we commit to building.
+> **Status:** DECISIONS LOCKED (2026-06-06). **Building slice 1 = the surface system + WS
+> transport (front-loaded)** — the user chose to prove the novel architecture first, not a quick
+> chat MVP. This is the design HOME for the web frontend; promote settled vocab/parts into the FE
+> repo's `GLOSSARY.md`/harness (and "surface" into the backend `GLOSSARY.md`) when slice 1 starts.
>
-> **Read order (fresh agent picking this up):** `ORCHESTRATOR.md` → `AGENTS.md` (the
-> backend methodology we are MIRRORING) → `GLOSSARY.md` → this file.
-> **Mode = IDEATION WITH the user** (design/discuss, do NOT build yet). The user owns the
-> boundary (§5.2) + vocabulary (§5.6) calls.
-> **Driver:** a minimal chat frontend, **Svelte + DaisyUI** (same stack family as old
-> Dispatch), built with the SAME methodology as the backend — NOT a default-SvelteKit ball
-> of mud. Old FE at `/home/tradam/projects/dispatch/dispatch-source` is REFERENCE-ONLY.
-> Ports reserved: `FRONTEND_PORT=24204` (.env).
+> **Read order (fresh agent):** `ORCHESTRATOR.md` → `AGENTS.md` (the backend methodology we
+> MIRROR) → `GLOSSARY.md` → `notes/restructure-plan.md` (P1–P8, §-refs) → this file.
+> **The user owns boundary (§5.2) + vocabulary (§5.6) calls.**
+> **Driver:** a minimal chat frontend, **Svelte 5 + DaisyUI**, in a **SEPARATE repo** (`../`),
+> built with the SAME discipline as the backend. Old FE at
+> `/home/tradam/projects/dispatch/dispatch-source` is REFERENCE-ONLY (UX/tech, NOT structure).
+> Port reserved: `FRONTEND_PORT=24204` (.env).
---
-## 0. Goal in one paragraph
-A minimal browser chat client — the FE analogue of the curl MVP: send a message and render
-the streamed, multi-turn response (`conversationId` threads history). Svelte + DaisyUI for
-the view; but the architecture must be a **minimal core + feature modules** with the same
-discipline that makes the backend testable and agent-buildable.
-
-## 1. The hard constraint — methodology parity with the backend (why this needs care)
-Translate each backend principle to the frontend (these are the constraints, not yet the
-"how"):
-- **Minimal core + feature modules / tiers.** A FE "kernel" that owns app shell + routing +
- state-core + a module host, and **names no concrete feature**; every feature (chat view,
- conversation list, composer, message-stream renderer, settings…) is a module/"extension".
-- **Contracts are the only cross-unit surface.** Cross-module coupling anchored to **typed
- symbols** (no string-keyed lookups → must be a compile error so `lsp references` finds
- every consumer). The **FE↔BE seam** is the backend's HTTP/event contract (the
- `AgentEvent` union + `/chat` NDJSON + `conversationId`) — ideally a **shared typed
- contract** so `lsp references` spans the boundary.
-- **Pure-core / inject-effects + no ambient state.** Pure view-models / stores / reducers /
- formatters: zero DOM, zero I/O, exhaustively unit-testable. Svelte components + transport
- (`fetch`/streaming) + browser effects (localStorage, history, clipboard) are the
- **injected imperative shell**.
-- **One owner per unit**; orchestrator summons owner-agents; units communicate via
- contracts; the orchestrator never edits implementation.
-- **Asymmetric testing** — strict zero-internal-mock + high coverage on pure logic; lenient
- integration on components/shell. Mocking our own module = a design bug.
-- **Durability where it matters** (e.g. optimistic UI + reconcile on reconnect) — pure
- `reconcile(state, events)`.
-
-## 2. Open questions (DECIDE in the design pass — all UNDECIDED)
-- **FE "kernel" shape:** what exactly is core vs. feature? Module-host mechanism (manifest
- analogue?) vs. simpler composition. How far to take the kernel/extension metaphor before
- it's cargo-culting the backend (P6 — don't copy structure that earns nothing).
-- **Unit boundaries / first units** for the MVP (composer, transport client, message-stream
- store + renderer, conversation state). Granularity = USER's call.
-- **The FE↔BE contract package:** reuse kernel `AgentEvent`/types directly? a new shared
- `@dispatch/protocol` package both sides depend on? how do FE pure-cores import it.
-- **Transport in the browser:** consume the `/chat` NDJSON stream (fetch + ReadableStream
- reader) — framing, backpressure, abort, reconnect, `conversationId` threading. (Note:
- `trace-replay`'s fixture model could even feed FE transport tests hermetically.)
-- **State approach:** Svelte stores vs runes; keep ALL logic framework-thin & pure so it's
- testable without mounting components.
-- **Testing tools:** vitest for pure logic (already in repo); component/integration via
- `@testing-library/svelte`; e2e via Playwright? — decide + how it joins `bun run test`.
-- **Build/tooling + monorepo placement:** `packages/frontend` vs `apps/web`; Vite + Svelte;
- Tailwind + DaisyUI; how it fits `tsc -b` project refs, biome, the bun workspace.
-- **Harness artifacts to author:** new scoped `.dispatch/rules/frontend-*.md` (the FE
- pure-core/shell + inject-effects + no-ambient-state rules), GLOSSARY terms (no
- synonym-drift with backend vocab), ORCHESTRATOR additions for FE summons, and the
- AGENTS.md scope update (**the current "Backend only for now (no frontend)" line retires
- when FE build starts** — leave it until then).
-- **MVP scope cut:** what's in v1 (send + stream + multi-turn render) vs. deferred (history
- list, tool-call/▷thinking rendering, settings, theming).
-
-## 3. Decisions settled
-- (none yet — IDEATION.)
-
-## 4. References (do NOT copy blindly — keep our methodology)
-- Old Dispatch FE: `/home/tradam/projects/dispatch/dispatch-source` (Svelte + DaisyUI) —
- reference-only for UX + tech, NOT structure.
-- Backend seam: `packages/kernel/src/contracts/events.ts` (`AgentEvent`),
- `packages/transport-http` (`/chat` NDJSON), `GLOSSARY.md`.
+## 0. Goal
+A minimal browser chat client — the FE analogue of the curl/CLI MVP: send a message, render the
+streamed multi-turn response (`conversationId` threads history). The architecture is a **thin
+shell + pure feature libraries** with the discipline that makes the backend testable and
+agent-buildable, PLUS a **backend-declared, frontend-agnostic UI surface system** so backend
+extensions expose UI without any per-extension frontend code.
+
+---
+
+## 1. The methodology call: port the PRINCIPLES, not the STRUCTURE (P4)
+
+The biggest risk is cargo-culting our own backend. The kernel/extension-host/manifest/DAG/
+capability-gate machinery exists to solve backend problems a browser app does **not** have:
+
+| Backend machinery | Why it exists | FE has that problem? |
+|---|---|---|
+| Extension host + manifests + DAG | dynamic, runtime-loaded, third-party features | No — Vite bundles at build time |
+| Capability gate (fs/shell/net/secrets) | untrusted code touching real I/O | No — the browser sandbox gates |
+| `runTurn` / turn loop | the product *is* the loop | No — the FE renders a stream |
+| On-disk durability / `reconcile` | crash-safe persisted history | Smaller (a client cache; §6) |
+
+**What ports (the load-bearing parts):** P1 (feature-as-a-library), P2 (functional core / inject
+effects), P3 (no ambient state), one-owner-per-unit, asymmetric testing, typed coupling (§5.4),
+and the harness (P5–P8). These solve *real* FE problems — the old FE's "tools leak across tabs" /
+"model resets on tab switch" were textbook **P3 ambient-state bugs**.
+
+**What replaces the host:** an ordinary **composition root** (the FE's `host-bin`) — one place
+that imports feature modules and wires them with typed calls. No registry-of-code, no manifest.
+
+**The one place a discovery seam DOES earn its place (P4 cuts both ways):** because backend
+extensions must expose UI that the FE surfaces, we DO need a lightweight **surface discovery**
+seam (§4). This is the backend's own split — *contracts are static TYPES; loading is dynamic*
+(§5.4) — applied at the FE↔BE boundary, NOT the heavy host machinery.
+
+**No mandatory spine — composition over privilege.** The FE is a **composition of feature
+modules + a surface host**, assembled per-frontend (or per-route) at the composition root. NO
+feature is globally privileged: chat is the central *product* feature but not the structural
+root — legitimate frontends compose without it (an agent editor, a read-only history explorer, a
+project viewer that mounts chat only after a project is picked). "Core" is whatever a given app
+composes; the teeth — dependency-direction + "works without optional surfaces" — apply to that
+chosen set. Skip the backend's 3-tier ceremony.
+- **Feature modules** = bespoke, contract-backed UI (chat, agent-editor-if-bespoke, history
+ explorer); each is feature-as-a-library, includable or not.
+- **Surfaces** (§4) = generic, backend-declared declarative UI for the long tail (and for tools
+ that fit the semantic catalog).
+- **Chat is a feature, NOT a surface** — different data lifecycle (append-only/cached/`seq` vs.
+ live/ephemeral, §6.2) + no genericity payoff (a transcript always needs bespoke rendering). It
+ **decomposes** into a read-side (transcript/history) + write-side (composer/live) so a history
+ explorer reuses just the read-side.
+- Dependency-direction rule: features depend on `core`/`wire`, never on each other; the shell
+ composes features, never the reverse.
+
+---
+
+## 2. Principle translation (P1–P8 → FE)
+
+| Principle | FE translation |
+|---|---|
+| **P1 feature-as-a-library** | each feature is a self-contained module with a clean typed surface, importable alone |
+| **P2 functional core / inject effects** | pure reducers/view-models/formatters; Svelte + WS/`fetch` + storage are the injected shell. The conversation state machine is `reduce(state, AgentEvent) → state` — unit-tested with zero component mounting |
+| **P3 no ambient state** | per-conversation state owned explicitly; runes/stores are a thin reactive wrapper over the pure reducer, not the home of logic |
+| **P4 don't adopt by reputation** | the surface system, tiers, transport — each earns its place against a named need; grow the catalog from real demand |
+| **§5.4 typed coupling** | cross-feature links are typed imports/callbacks; no stringly-typed event bus. Discovery-by-id (catalog, subscribe) is sanctioned *data flow*, not a code reference |
+| **one owner + asymmetric testing** | one owner-agent per feature module; strict zero-mock on `logic/`, thin component/e2e on `ui/` |
+| **P5–P8 harness** | repo-scoped harness travels with the FE (§8); vocabulary shared with the backend verbatim |
+
+---
+
+## 3. Repo structure (Vite + Svelte 5 SPA — SETTLED; not SvelteKit)
+
+```
+dispatch-web/ (../dispatch-web — NEW repo, own git)
+ AGENTS.md ORCHESTRATOR.md GLOSSARY.md
+ .dispatch/{package-agent.md, rules/frontend-*.md}
+ src/
+ app/ SHELL + composition root (the ONLY place that names features)
+ core/ PURE, framework-free, zero I/O:
+ transcript/ events → Chunk[] reducer (the single render model, §6)
+ cache/ reconcileCache / selectEvictions (pure; injected IndexedDB)
+ surfaces/ the surface interpreter core (pure: spec → view-model)
+ protocol/ the transport-agnostic op-protocol core (pure state machine, §5)
+ wire/ imported wire + ui contracts (types only)
+ features/ feature-as-a-library modules (logic/ pure · ui/ svelte · adapter/ effects)
+ adapters/ injected browser effects: WS client, fetch, IndexedDB, history, clipboard
+ vite + tsconfig + biome + vitest (+ @testing-library/svelte; Playwright later)
+```
+
+---
+
+## 4. The surface model — backend-declared, frontend-agnostic UI (the centerpiece)
+
+### 4.1 What a surface is
+A **surface** is a backend-declared **"data transportation surface"**: a typed data structure
+describing *what data exists, its semantics, and what actions can act on it* — NOT UI. The
+backend transports **structure + semantics + actions**; the client owns **100% of presentation**.
+Field kinds are *semantic*, not visual: `toggle` = "a boolean + an action" (not "draw a switch");
+`progress` = "a bounded ratio + a label" (not "draw a bar").
+
+This is the disciplined variant of Server-Driven UI: the server says *what the data means*, it
+**never dictates how it looks**. It is the same principle that already lets the CLI and web share
+one wire contract (`transport-contract`), generalized from the chat wire to *all* UI intent: one
+surface renders as a DaisyUI switch on web, a `[y/n]` prompt in the CLI, a tap-switch on mobile —
+same data, three renderers, zero backend awareness of any of them.
+
+### 4.2 The shape (semantic; names are hints, the contract is the data shape)
+```ts
+SurfaceSpec = { id; region; title; fields: SurfaceField[] } // ordered
+SurfaceField =
+ | { kind: "toggle"; label; value: boolean; action: ActionRef }
+ | { kind: "progress"; label; value: number /* 0..1 */ }
+ | { kind: "selector"; label; value; options: Option[]; action: ActionRef }
+ | { kind: "stat"; label; value: string }
+ | { kind: "button"; label; action: ActionRef }
+ | { kind: "custom"; rendererId; payload } // the escape hatch — see guardrail 2
+ActionRef = a typed reference the client passes back to invoke a backend action
+```
+`region` = where the surface mounts (placement). `kind` = the field's semantic type. Names are
+training-baked (P8 — no need to invent "BoundedRatioQuantity").
+
+### 4.3 The frontend-agnostic invariant (load-bearing)
+**The backend depends only on the semantic surface contract and on ZERO rendering technology —
+so swapping Svelte→React, or adding a TUI/mobile client, is a zero-backend-change event.** The
+contract carries coarse placement (which region, title, field order) + semantics + actions —
+**never styling, never pixels, never a CSS/DaisyUI token.** DaisyUI is purely the Svelte
+renderer's private business; the backend has never heard of it.
+
+### 4.4 Discovery + opt-in subscription (no firehose)
+The FE is in control of what it observes — the backend never pushes everything continuously.
+The system has three interaction kinds; **only the live part is transport-coupled:**
+
+| Interaction | Shape |
+|---|---|
+| Discovery — the **surface catalog** | `GET /surfaces` → `[{ id, title, region, kind }]` (metadata only, no data) |
+| One-shot read — current spec | `GET /surfaces/:id` → full `SurfaceSpec` + values |
+| Fire an action | `POST /surfaces/:id/actions/:actionId` |
+| **subscribe / unsubscribe + live updates** | the WS op-protocol (§5) — pushes patches for subscribed surfaces ONLY |
+
+```
+connect → GET /surfaces (catalog) → user selects X → GET /surfaces/X (+ subscribe X) →
+ patches for X stream until unsubscribe(X) → close X → unsubscribe → traffic for X stops
+```
+Catalog changes (extension toggled) are low-frequency → a lightweight "catalog-invalidated"
+ping or re-pull, not a stream. The backend builds a spec **lazily** — only for queried/
+subscribed surfaces.
+
+### 4.5 Isolation guardrails (why this is isolation-aligned — the audit rationale)
+The surface-as-data approach is the **isolation-maximal** design: extension↔view coupling
+collapses to ONE typed contract (the sanctioned shared surface), the extension imports zero
+frontend, the FE imports zero extension code. These invariants keep it that way:
+
+1. **The interpreter stays generic — forbid any `if (surface.id === "...")`.** The shared
+ interpreter + widget catalog is sanctioned *platform* (justified like the kernel ABI). The
+ instant it special-cases a known surface, it has imported a feature's identity and isolation
+ breaks. Rule: the interpreter knows field *kinds*, never surface *identities*.
+2. **`custom` is the one isolation compromise — minimize and type it.** A client-local renderer
+ for a `custom` payload recouples a FE unit to one extension. Keep it rare (P4/P6). The
+ `custom` payload type must be **exported from the owning extension's contract** so the
+ bespoke renderer imports a typed symbol (lsp-traceable), not a blind `unknown`.
+3. **A surface owns only its OWN data + actions.** It must never reference another extension's
+ action/state — cross-extension needs go through the normal typed service/hook path, never
+ surface data. (Actions are intra-extension: the surface and its handler share one owner.)
+4. **Action/live-update state is owned per-surface and reconciled purely** (P3). Read-only
+ surfaces are trivially clean; the moment toggles fire, route through `reconcile(state, update)`
+ with explicit ownership.
+5. **The agnostic invariant is enforced** (§4.3): no styling/framework token may appear in the
+ ui-contract. Lint/review rule.
+6. **Subscriptions are explicitly owned + disposed; specs built lazily.** The backend never
+ eagerly materializes all surfaces; the FE owns its subscription set as explicit state and
+ tears it down on unmount. No ambient subscription registry.
+
+### 4.6 Declarative-first, bespoke as escape hatch
+- **Tier 1 (the path):** declarative semantic surfaces over the fixed catalog — settings,
+ toggles, progress, info, the future "views" (§9). Zero FE code per extension.
+- **Tier 2 (escape hatch):** (a) *prettier rendering than the generic one* = purely client-local
+ (no contract impact, agnostic intact); (b) *data fits no primitive* = the `custom` kind, opt-in
+ per client, graceful-skip when a client has no renderer for that `rendererId`.
+
+### 4.7 Catalog growth (P4)
+Slice 1 builds the surface *contract + interpreter + WS* and proves it against **one real first
+surface** (TBD — §10 "To start"). Grow the catalog (`toggle, progress, selector, stat, button` +
+`custom`) from real demand, never speculatively.
+
+---
+
+## 5. Transport — agnostic op-protocol + WebSocket, carrying BOTH (SETTLED)
+
+Define the protocol as **logical ops with a pure core**, then the carrier is an injected adapter
+(swappable/testable):
+```
+ops (the contract): getCatalog · getSurface(id) · subscribe(id) · unsubscribe(id)
+ · onUpdate(id, patch) · invokeAction(id, actionId, payload)
+ · sendMessage(...) · onDelta(AgentEvent)
+pure core (P2): reduce(intent, incoming) → { viewModel, outgoingCommands }
+injected shell: the WS client (web) OR REST+stream (one-shot clients)
+```
+- **Carrier = WebSocket, up front, for BOTH** live turn-deltas AND surface updates over ONE
+ persistent connection (+ a small reconnect/router). The connection IS the subscription session
+ → subscriptions die with the socket (clean lifecycle, guardrail 6). Bun's WS is first-class; the
+ old app proved a reconnecting WS client here.
+- **REST `POST /chat` (NDJSON) is retained for one-shot clients (the CLI)** — no WS needed; plus
+ discovery, actions, and incremental history sync (§6). **Same `AgentEvent`s, different
+ carriers** — exactly "inject the transport."
+- Chosen over SSE+POST: the subscription model wants a session whose lifetime = the connection
+ (SSE needs a server-side per-stream registry + GC); Bun ergonomics + precedent; bidirectional
+ sub/unsub without a correlation id. (SSE+POST remains a documented alternative behind the same
+ ops if proxy/CDN/curl-debuggability ever dominate.)
+
+---
+
+## 6. Chats — caching + delta streaming
+
+### 6.1 The enabler
+The backend §3.4 durability gives us the hard part: history is an **append-only chunk log** (past
+turns never mutate) with a **monotonic per-conversation cursor**, and `reconcile(rows)` yields a
+valid history on load. Therefore:
+> **The client cache is a pure performance optimization over an authoritative, incrementally-
+> syncable backend log. Wiping it is correctness-neutral — worst case is a re-fetch.**
+That is what makes reliable caching + aggressive purging *safe*.
+
+### 6.2 Two data lifecycles — they cache OPPOSITELY
+| | Chats (history) | Surfaces |
+|---|---|---|
+| Nature | append-only, immutable below the seal | live current-state |
+| Client caching | **yes** — durable, incrementally synced, purgeable | **no durable cache** (stale = wrong; "show stale, update" at most) |
+| Sync | "give me chunks after seq N" | subscribe → push, current-only |
+
+### 6.3 Delta streaming fits via the seal boundary + one reducer
+Live `AgentEvent` deltas are the **in-flight turn** — ephemeral. They fold into the canonical
+`Chunk[]` via the one pure reducer (`appendEventToChunks` pattern). **`turn-sealed` = the
+cache-commit signal:** below the last seal is immutable + cacheable; the in-flight turn is
+provisional (in-memory) until it seals. **Sync granularity (per-chunk `seq`) ≠ commit granularity
+(per-turn seal)** — finer sync, turn-atomic caching.
+```
+ IndexedDB cache (committed chunks) ─┐
+ REST sync: chunks since seq N ─┼─► reduce → Chunk[] ─► render
+ WS: live deltas (active turn) ─┘ ▲ turn-sealed ⇒ commit provisional turn to cache
+```
+Three sources, ONE reducer, ONE shape — the one-render-model decision paying off.
+
+### 6.4 Cache design (mapped to principles)
+- **P2/P3:** pure `reconcileCache(cached, incoming, seq) → { nextCache, whatToFetch }` and
+ `selectEvictions(index, budget) → toEvict`; storage (**IndexedDB**) is the injected shell.
+ The mirror of the backend's `reconcile`.
+- **Isolation/P1:** a self-contained `conversation-cache` feature; depends only on the wire
+ contract (chunks + `seq` + `turn-sealed`).
+- **Symmetry:** the FE cache = the backend's durability discipline applied client-side
+ (append-only, seq-keyed, reconcile-on-load, derived status).
+- **"Don't pass all data constantly" — satisfied:** the wire only ever carries (a) live deltas
+ for the active turn and (b) the incremental tail since the client's `seq`. Cached chunks are
+ served locally, never re-fetched.
+
+### 6.5 Purging (safe, simple-first — P4)
+Eviction is re-syncable, so start simple: byte/turn budget; LRU by conversation + evict oldest
+sealed turns when over budget; **never evict the active conversation**. Defer per-chunk windowing
+/ scroll-back rehydration until a conversation is big enough to need it.
+
+### 6.6 Honest subtleties
+- **Interrupted-turn tail vs `reconcile`:** commit to cache only on `turn-sealed`; a provisional
+ tail is always replaceable by the next sync. No stale-tail risk.
+- **Multi-tab / CLI+web convergence:** append-only + `seq` ⇒ a monotonic merge (each client
+ pulls chunks after its own `seq`). Only breaks if we ever allow editing/deleting history — we
+ don't (append-only).
+- **Storage medium is an injected detail** — IndexedDB is the likely choice; the pure core
+ doesn't care.
+
+---
+
+## 7. Backend contract changes for FE-friendliness
+
+**Shapes are right — don't churn them** (`ChatRequest`/`ModelsResponse`/`AgentEvent` proved live
+via the CLI). The changes are about **packaging**, **read-side coverage**, and the **surface +
+WS** seam, because a FE is long-lived, reloadable, multi-conversation, and surfaces extensions.
+
+- **`transport-contract` self-containment — DECIDED: split a types-only kernel sub-package.** The
+ pure wire/event types move to a types-only package that both `@dispatch/kernel` and
+ `transport-contract` re-export → `AgentEvent` stays single-source and the FE repo depends on no
+ runtime.
+- **New shared `@dispatch/ui-contract`** (types-only): the semantic field-kind catalog + `region`
+ vocabulary + action protocol + surface-catalog types (§4). Consumed by the backend (to declare),
+ web, and CLI — **not** anything Svelte.
+- **Surface + WS seam:** the surface-contribution mechanism (kernel/host carries it generically;
+ extensions declare surfaces), `GET /surfaces` (catalog) + `GET /surfaces/:id` + `POST
+ /surfaces/:id/actions/:actionId`, and the **WS channel** multiplexing turn-deltas + surface
+ ops (§5).
+- **Read-side endpoints:**
+ | FE need | Today | Proposed |
+ |---|---|---|
+ | Reload a transcript | history only as the turn's own stream | `GET /conversations/:id?sinceSeq=<seq>` → reconciled `ChatMessage[]`/`Chunk[]` (incremental) |
+ | Conversation list / sidebar | none | `GET /conversations` → `[{ conversationId, title?, updatedAt, status }]` (later slice) |
+ | "Stop generating" | old `/chat/cancel` never rebuilt | `POST /conversations/:id/cancel` (later slice) |
+- **Monotonic cursor on the wire — DECIDED: per-chunk `seq`.** `Chunk` carries no cursor today;
+ add a per-chunk `seq` (finer than turn-granular; allows mid-turn sync). Cache still commits at
+ the turn seal (§6.3).
+- **Render-model alignment:** history returns the same `Chunk[]`/`ChatMessage[]` the live stream
+ folds into → ONE FE reducer. (Proven: old `chunks/append.ts` + DB-free `transform.ts`.)
+- **Separate-repo consequence:** `lsp references` no longer spans the FE↔BE seam → the dormant
+ **§2.9 semver discipline wakes up** (the FE pins a contract version; a `major` bump is the
+ fan-out signal). A thin "contract conformance" type-test in the FE catches shape drift the
+ cross-repo compiler can't.
+
+---
+
+## 8. The harness to set up (repo-scoped — P7)
+
+Because it's a separate repo, the harness travels with it. The new repo needs its own:
+- **`AGENTS.md`** — FE constitution: pure view-models, inject browser effects, no ambient
+ cross-component state, Svelte-thin, one-owner, asymmetric testing.
+- **`.dispatch/rules/frontend-*.md`** — 3–5 line reflexes in the existing format, e.g. *"Logic
+ modules import no Svelte and no `fetch`/DOM — effects are injected"*; *"State is owned
+ per-conversation and passed in; no module-global mutable store"*; *"The NDJSON/WS parser is
+ pure (bytes→events); inject the socket"*; *"The surface interpreter knows field kinds, never
+ surface ids."*
+- **`.dispatch/package-agent.md`** — owner-agent brief adapted so "unit" = feature module; verify
+ = `vitest` (pure) + component tests.
+- **`ORCHESTRATOR.md`** — FE summon manual. **DECIDED: per-repo harness** — FE summons run with
+ cwd = FE repo root + its own TS language server. **Cross-repo bridge:** an owner-agent or the
+ orchestrator may **ask the USER to look at the other (back/front) repo** when a change spans the
+ seam — the user is the cross-repo courier (since `lsp` can't span repos).
+- **`GLOSSARY.md`** — adopt the backend's canonical terms **verbatim** (no drift):
+ `conversation`/`conversationId`, `turn`, `step`, `chunk`, `tool call`, `model name`,
+ `model catalog`, `AgentEvent`. Duplicating these is the intended trade (isolation-over-DRY:
+ share knowledge, not runtime code).
+
+**In THIS repo, when slice 1 starts** (per `tasks.md` ROADMAP §2): retire the AGENTS.md
+"Backend only for now (no frontend)" line; update `ORCHESTRATOR.md` §7 (geography) + §3
+(rule-scoping map). `FRONTEND_PORT=24204` reserved.
+
+---
+
+## 9. Vocabulary (§5.6 — human-gated; SETTLED 2026-06-06)
+
+- **surface** — a backend-declared, **frontend-agnostic** semantic data contribution (a "data
+ transportation surface"): fields + values + bound actions; structure + semantics + actions,
+ **never styling**. The backend *exposes* surfaces; any client renders them in its own idiom.
+- **view** — an **old-Dispatch FE term, DEFERRED/RESERVED.** A sidebar element the user could
+ open; it took a spot in the sidebar and displayed a **settings view** or a **feature-specific
+ view** (e.g. cache reheating). A FE rendering affordance — conceptually the place a surface (or
+ settings) gets shown. The user liked the old interface and will **revisit "views" later**; the
+ term must not be reused meanwhile. (NB: avoid a `side-view` region name — it overlaps; leave
+ region names open until views are revisited.)
+- **region** — *where* a surface mounts (the coarse placement). Chosen over "slot" to avoid
+ clashing with Svelte's `<slot>`.
+- **field kind** — the semantic type of a field (`toggle`/`progress`/`selector`/`stat`/`button`/
+ `custom`); the discriminant the interpreter switches on.
+- **action / action ref** — the FE term for a backend-invokable action; a field carries an
+ **action ref** the client posts back. **Backend keeps `command` for now** (its existing
+ contribution point); a future review to unify `command` → `action` is logged in
+ `notes/restructure-plan.md` §8 (deferred). Do NOT rename `command` in the backend yet.
+- **surface catalog** — the list of available surfaces (metadata) the FE fetches to discover them
+ (`GET /surfaces`). Parallels the existing **model catalog**. ("capability manifest" was
+ considered and **dropped** — it overloaded "manifest" and was redundant with this.)
+
+Relationship: a *surface* is backend data; a *view* (future) is a FE rendering slot that displays
+a surface. (Promote "surface" + this vocab to the backend GLOSSARY + the FE GLOSSARY when slice 1
+starts.)
+
+---
+
+## 10. Decisions
+
+### Settled
+- **Slice order: surface system + WS FIRST** (front-load the architecture), then cache/reload,
+ then chat polish / conversation list / theming.
+- Methodology = port principles, not the heavy host machinery; thin shell + pure feature
+ libraries + a lightweight surface-discovery seam (§1).
+- No mandatory spine: a composition of feature modules + a surface host; "core" is per-frontend;
+ chat is a (decomposable) feature, not a surface (§1).
+- Surface model = backend-declared, frontend-agnostic "data transportation surfaces"; semantic
+ field kinds; client owns 100% of presentation; isolation guardrails 1–6 (§4).
+- Discovery (surface catalog) + opt-in per-surface subscription; no firehose; lazy spec build (§4.4).
+- Transport = agnostic op-protocol with **WebSocket carrying BOTH** turn-deltas + surfaces; REST
+ `/chat` retained for one-shot/CLI (§5).
+- Caching/streaming = append-only + **per-chunk `seq`** source of truth; `turn-sealed` =
+ cache-commit; three-source → one-reducer → one `Chunk[]`; pure `reconcileCache`/
+ `selectEvictions` + injected IndexedDB; safe aggressive purging (§6).
+- Stack = **Vite + Svelte 5 SPA** (not SvelteKit); testing = vitest + `@testing-library/svelte`
+ (Playwright later) (§3).
+- `transport-contract` self-containment = **split a types-only kernel sub-package** (§7).
+- Orchestration = **per-repo harness** + user-as-cross-repo-courier bridge (§8).
+- Surface internals = recommended defaults, finalized as slice 1 details land: catalog =
+ `toggle/progress/selector/stat/button` (+`custom`); on/off = config+restart for now (runtime
+ enable/disable endpoint = a future backend pass); v1 interactivity = read-only + simple
+ toggles/buttons (defer rich forms/validation).
+- Vocabulary (§9): `surface`, `view` (reserved), `region`, `field kind`, `action`/`action ref`
+ (backend stays `command`, future review), `surface catalog`.
+
+### Slice 1 — BUILT + verified live (2026-06-06) ✅
+The surface system, end-to-end: `ui-contract` (surface ABI + WS protocol), `surface-registry`
+(typed service), `transport-ws` (Bun WS server on :24205, path-agnostic upgrade),
+`surface-loaded-extensions` (first real surface), kernel `getExtensions`; FE `core/protocol` +
+`features/surface-host` (interpreter + field components) + `adapters/ws` + `app` composition root.
+**Live WS probe: catalog → subscribe → surface rendered the 10 loaded extensions** as stat fields.
+Backend 460 vitest + 77 bun, FE 76 tests; both repos typecheck + biome clean. Deferred: F-app CR-1
+(vitest `browser` resolve condition, for component-render tests); B2 kernel wire-types split (chat
+slice); DaisyUI styling (F4 follow-up). The slice-1 input decisions were:
+1. **The first real surface** to build + prove the system against (the §4.7 demo). Needs a
+ concrete feature — e.g. a read-only server/model **stat** surface first (proves discovery →
+ subscribe → render with no action round-trip), then add a **toggle** to prove the action path.
+ Or pick a different first surface.
+2. **WS-transport boundary:** a NEW `transport-ws` extension vs. augment the existing
+ `transport-http`. (Boundary call.)
+3. **Repo scaffold go-ahead:** create `../dispatch-web` (git init + Vite/Svelte/biome/vitest +
+ the §8 harness). Orchestrator can scaffold config + harness; feature code = summoned agents.
+
+### Slice-1 findings + open items (for the next, clean-context agent)
+- **Bug found + FIXED — transport-ws WebSocket upgrade.** The pure router unit-tested green, but
+ the live `Bun.serve` shell gated `server.upgrade()` behind a path (`/ws/surfaces`), so a client
+ hitting `ws://host:24205/` got "Expected 101" instead of an upgrade. **Only the live WS probe
+ caught it** — pure unit tests can't. Fixed (path-agnostic upgrade; `426` for non-WS) + a
+ `bun:test` server test (`packages/transport-ws/src/server.bun.test.ts`). **LESSON (reinforces
+ `restructure-plan.md` §3.6): a transport/server SHELL needs a thin LIVE integration test — the
+ effectful shell is exactly where integration bugs hide.** Apply to every future transport unit.
+- **"10 vs 11 extensions" was a PROBE ARTIFACT, not a bug.** A boot-probe that exported an EMPTY
+ `DISPATCH_API_KEY` (it grepped a var absent from `.env`) made `provider-openai-compat` skip
+ activation → the surface showed 10. Booted normally (`bin/up` / `.env`'s real key) all **11**
+ activate (incl. "OpenAI-Compatible Provider") — verified live. Scar tissue: run live probes with
+ the real env, never an overridden empty key.
+- **§8 reinforced (it cost me twice this session).** Boot-probes LEAK servers → the next boot hits
+ `EADDRINUSE`; ALWAYS sweep with the bracket-trick `pkill` afterward. And a single
+ boot+probe+cleanup bash command HANGS the tool — boot it, read the probe's `RESULT` + the
+ surface from the LOG FILE, then run a SEPARATE cleanup command. (See `ORCHESTRATOR.md` §8.)
+- **OPEN — FE component-render tests (CR-1).** `dispatch-web/vite.config.ts` needs Svelte's
+ `browser` resolve condition (or the `@testing-library/svelte/vite` `svelteTesting()` plugin) for
+ `@testing-library/svelte` `render()` under vitest/jsdom. The 84 logic/store/resolver tests pass
+ without it; it only gates DOM component-render tests. Apply when those get written.
+
+---
+
+## 11. References (do NOT copy blindly — keep our methodology)
+- Old Dispatch FE: `/home/tradam/projects/dispatch/dispatch-source` (Svelte + DaisyUI) — UX/tech
+ reference only, NOT structure (esp. the "views" sidebar UX the user wants to revisit, §9).
+- Backend seam: `packages/transport-contract/src/index.ts` (`ChatRequest`/`ModelsResponse` +
+ re-exported `AgentEvent`), `packages/kernel/src/contracts/events.ts` (`AgentEvent`),
+ `packages/kernel/src/contracts/conversation.ts` (`Chunk`/`ChatMessage`).
+- Durability/cache basis: `notes/restructure-plan.md` §3.4 (append-only + `reconcile`).
+- Methodology: `notes/restructure-plan.md` §1 (P1–P8), §2.9 (versioning), §5.4 (typed coupling),
+ §5.6 (glossary), `AGENTS.md`, `.dispatch/rules/`.
+```
diff --git a/notes/restructure-plan.md b/notes/restructure-plan.md
index 1c9e5e6..e6565c2 100644
--- a/notes/restructure-plan.md
+++ b/notes/restructure-plan.md
@@ -1387,6 +1387,12 @@ and permissions. Two consequences:
active" sync (§5.1) — start with fresh-summoned agents.
- TypeScript language server wired into `dispatch.toml` is a **prerequisite**
for §5.3's `lsp references` workflow (today only Luau is configured).
+ - **Vocabulary unification — `command` → `action` (P8; raised during the frontend design,
+ `notes/frontend-design.md` §9):** the frontend names a backend-invokable action
+ `action` / `action ref`; the backend's existing contribution point is `command`. Review
+ renaming `command` → `action` so both sides share ONE term. Until this review the backend
+ keeps `command` and the frontend uses `action`. Cheap today (the `command` contribution is
+ design-stage, lightly built); if pursued, fan out via `lsp references`.
- **Decided so far:**
- ~~Tool-dispatch default policy~~ — **DECIDED** (§3.3): default
`{ maxConcurrent: 1, eager: true }`.
diff --git a/package.json b/package.json
index c1e0de1..c2cfbd8 100644
--- a/package.json
+++ b/package.json
@@ -11,9 +11,10 @@
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -b --pretty",
- "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src",
+ "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts",
"test:all": "bun run test && bun run test:bun",
"dev": "bun packages/host-bin/src/main.ts",
+ "dev:all": "./bin/up",
"dispatch": "bun packages/cli/src/main.ts"
},
"devDependencies": {
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index 4ce63a9..65e986d 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -13,6 +13,9 @@
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/transport-http": "workspace:*",
"@dispatch/tool-read-file": "workspace:*",
- "@dispatch/journal-sink": "workspace:*"
+ "@dispatch/journal-sink": "workspace:*",
+ "@dispatch/surface-loaded-extensions": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/transport-ws": "workspace:*"
}
}
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index d766e46..68cbbdd 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -21,8 +21,11 @@ import {
import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat";
import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator";
import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite";
+import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions";
+import { createSurfaceRegistryExtension } from "@dispatch/surface-registry";
import { extension as toolReadFileExt } from "@dispatch/tool-read-file";
import { createServer, extension as transportHttpExt } from "@dispatch/transport-http";
+import { createTransportWsExtension } from "@dispatch/transport-ws";
import type { ChildHandle } from "./collector-supervisor.js";
import { createCollectorSupervisor } from "./collector-supervisor.js";
import { configMapToAccess, envToConfigMap } from "./config.js";
@@ -61,6 +64,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [
}),
sessionOrchestratorExt,
transportHttpExt,
+ // Surface extensions — dependency order: surface-registry first, then consumers.
+ createSurfaceRegistryExtension(),
+ createTransportWsExtension(),
+ createLoadedExtensionsExtension(),
];
async function boot(): Promise<void> {
diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json
index 70ff95c..3630394 100644
--- a/packages/host-bin/tsconfig.json
+++ b/packages/host-bin/tsconfig.json
@@ -5,11 +5,10 @@
"references": [
{ "path": "../kernel" },
{ "path": "../storage-sqlite" },
- { "path": "../conversation-store" },
- { "path": "../auth-apikey" },
- { "path": "../credential-store" },
- { "path": "../provider-openai-compat" },
- { "path": "../session-orchestrator" },
- { "path": "../transport-http" }
+ { "path": "../surface-loaded-extensions" },
+ { "path": "../surface-registry" },
+ { "path": "../tool-read-file" },
+ { "path": "../transport-http" },
+ { "path": "../transport-ws" }
]
}
diff --git a/packages/kernel/package.json b/packages/kernel/package.json
index f613cac..d8d55a7 100644
--- a/packages/kernel/package.json
+++ b/packages/kernel/package.json
@@ -4,5 +4,8 @@
"type": "module",
"private": true,
"main": "dist/index.js",
- "types": "dist/index.d.ts"
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/wire": "workspace:*"
+ }
}
diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts
index c9ad0eb..ec9a389 100644
--- a/packages/kernel/src/contracts/conversation.ts
+++ b/packages/kernel/src/contracts/conversation.ts
@@ -1,91 +1,20 @@
/**
* Conversation model — the kernel's representation of a dialogue.
*
- * The kernel owns only the types and pure transforms. Persistence is a core
- * extension (conversation-store). A turn is one user→assistant cycle; a step
- * is one LLM round-trip within a turn. Chunks are append-only.
+ * Re-exported from @dispatch/wire so the kernel barrel surface stays
+ * byte-identical. The canonical definitions live in @dispatch/wire.
*/
-/** Who produced a message. */
-export type Role = "system" | "user" | "assistant" | "tool";
-
-/** Opaque identifier for a turn (one user→assistant cycle). */
-export type TurnId = string & { readonly __brand: "TurnId" };
-
-/** Opaque identifier for a step (one LLM round-trip within a turn). */
-export type StepId = string & { readonly __brand: "StepId" };
-
-/**
- * A chunk is one ordered piece of a message — the atomic unit of the
- * append-only conversation log. Discriminated by `type`.
- */
-export type Chunk =
- | TextChunk
- | ThinkingChunk
- | ToolCallChunk
- | ToolResultChunk
- | ErrorChunk
- | SystemChunk;
-
-/** A piece of plain text content from the assistant or user. */
-export interface TextChunk {
- readonly type: "text";
- readonly text: string;
-}
-
-/** A piece of model reasoning / thinking content (e.g. extended thinking). */
-export interface ThinkingChunk {
- readonly type: "thinking";
- readonly text: string;
-}
-
-/**
- * A model's request to run a tool. The kernel routes by `name`; the tool
- * implementation never sees this directly — it receives parsed `input` via
- * `ToolContract.execute`.
- */
-export interface ToolCallChunk {
- readonly type: "tool-call";
- readonly toolCallId: string;
- readonly toolName: string;
- readonly input: unknown;
-}
-
-/**
- * The result of a tool execution, attributed to the originating tool-call id.
- * The kernel guarantees every tool-call chunk gets exactly one result chunk
- * (synthesized if interrupted — see reconcile).
- */
-export interface ToolResultChunk {
- readonly type: "tool-result";
- readonly toolCallId: string;
- readonly toolName: string;
- readonly content: string;
- readonly isError: boolean;
-}
-
-/** An error that occurred during generation or tool dispatch. */
-export interface ErrorChunk {
- readonly type: "error";
- readonly message: string;
- readonly code?: string;
-}
-
-/**
- * A system-injected message (e.g. system prompt, context assembly output).
- * Kept distinct from text so the log records provenance.
- */
-export interface SystemChunk {
- readonly type: "system";
- readonly text: string;
-}
-
-/**
- * A chat message: a role plus an ordered sequence of chunks. Messages are the
- * unit passed to and from the provider; chunks are the unit persisted and
- * rendered.
- */
-export interface ChatMessage {
- readonly role: Role;
- readonly chunks: readonly Chunk[];
-}
+export type {
+ ChatMessage,
+ Chunk,
+ ErrorChunk,
+ Role,
+ StepId,
+ SystemChunk,
+ TextChunk,
+ ThinkingChunk,
+ ToolCallChunk,
+ ToolResultChunk,
+ TurnId,
+} from "@dispatch/wire";
diff --git a/packages/kernel/src/contracts/events.ts b/packages/kernel/src/contracts/events.ts
index 74e23fd..8737b02 100644
--- a/packages/kernel/src/contracts/events.ts
+++ b/packages/kernel/src/contracts/events.ts
@@ -1,122 +1,21 @@
/**
* Outward events — the event type the runtime emits to the outside world.
*
- * These are the events transport extensions push to clients, notification
- * extensions react to, and conversation-store uses for persistence.
- * Discriminated by `type`.
+ * Re-exported from @dispatch/wire so the kernel barrel surface stays
+ * byte-identical. The canonical definitions live in @dispatch/wire.
*/
-import type { Usage } from "./provider.js";
-
-/**
- * The union of all events the runtime emits outward during a turn.
- * Consumers (transport, persistence, notifications) pattern-match on `type`.
- */
-export type AgentEvent =
- | StatusEvent
- | TurnStartEvent
- | TurnTextDeltaEvent
- | TurnReasoningDeltaEvent
- | TurnToolCallEvent
- | TurnToolResultEvent
- | TurnToolOutputEvent
- | TurnUsageEvent
- | TurnErrorEvent
- | TurnDoneEvent
- | TurnSealedEvent;
-
-/** Status change for a conversation (e.g. idle → running). */
-export interface StatusEvent {
- readonly type: "status";
- readonly conversationId: string;
- readonly status: string;
-}
-
-/** A turn has begun. */
-export interface TurnStartEvent {
- readonly type: "turn-start";
- readonly conversationId: string;
- readonly turnId: string;
-}
-
-/** Incremental text content from the model during a turn. */
-export interface TurnTextDeltaEvent {
- readonly type: "text-delta";
- readonly conversationId: string;
- readonly turnId: string;
- readonly delta: string;
-}
-
-/** Incremental reasoning / thinking content during a turn. */
-export interface TurnReasoningDeltaEvent {
- readonly type: "reasoning-delta";
- readonly conversationId: string;
- readonly turnId: string;
- readonly delta: string;
-}
-
-/** The model has requested a tool to be run. */
-export interface TurnToolCallEvent {
- readonly type: "tool-call";
- readonly conversationId: string;
- readonly turnId: string;
- readonly toolCallId: string;
- readonly toolName: string;
- readonly input: unknown;
-}
-
-/** A tool has completed execution. */
-export interface TurnToolResultEvent {
- readonly type: "tool-result";
- readonly conversationId: string;
- readonly turnId: string;
- readonly toolCallId: string;
- readonly toolName: string;
- readonly content: string;
- readonly isError: boolean;
-}
-
-/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
-export interface TurnToolOutputEvent {
- readonly type: "tool-output";
- readonly conversationId: string;
- readonly turnId: string;
- readonly toolCallId: string;
- readonly data: string;
- readonly stream: "stdout" | "stderr";
-}
-
-/** Token usage for the current step or turn. */
-export interface TurnUsageEvent {
- readonly type: "usage";
- readonly conversationId: string;
- readonly turnId: string;
- readonly usage: Usage;
-}
-
-/** An error occurred during the turn. */
-export interface TurnErrorEvent {
- readonly type: "error";
- readonly conversationId: string;
- readonly turnId: string;
- readonly message: string;
- readonly code?: string;
-}
-
-/** The turn has completed (model finished generating). */
-export interface TurnDoneEvent {
- readonly type: "done";
- readonly conversationId: string;
- readonly turnId: string;
- readonly reason: string;
-}
-
-/**
- * The turn has been sealed — all chunks persisted, history is final.
- * This is the hook point for post-turn extensions (compaction, cache-warm).
- */
-export interface TurnSealedEvent {
- readonly type: "turn-sealed";
- readonly conversationId: string;
- readonly turnId: string;
-}
+export type {
+ AgentEvent,
+ StatusEvent,
+ TurnDoneEvent,
+ TurnErrorEvent,
+ TurnReasoningDeltaEvent,
+ TurnSealedEvent,
+ TurnStartEvent,
+ TurnTextDeltaEvent,
+ TurnToolCallEvent,
+ TurnToolOutputEvent,
+ TurnToolResultEvent,
+ TurnUsageEvent,
+} from "@dispatch/wire";
diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts
index 00b41f1..1760cf9 100644
--- a/packages/kernel/src/contracts/extension.ts
+++ b/packages/kernel/src/contracts/extension.ts
@@ -232,6 +232,9 @@ export interface HostAPI {
/** Look up a single auth provider by id. */
readonly getAuthProvider: (id: string) => AuthContract | undefined;
+ /** Read-only view of all activated extensions' manifests (what is loaded). */
+ readonly getExtensions: () => readonly Manifest[];
+
/** Register a scheduled job with the host's scheduler. */
readonly scheduler: {
readonly register: (job: ScheduledJob) => void;
diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts
index ee58c1d..0686c19 100644
--- a/packages/kernel/src/contracts/provider.ts
+++ b/packages/kernel/src/contracts/provider.ts
@@ -6,20 +6,12 @@
* translates its responses into `ProviderEvent`s.
*/
+import type { Usage } from "@dispatch/wire";
import type { ChatMessage } from "./conversation.js";
import type { Logger } from "./logging.js";
import type { ToolContract } from "./tool.js";
-/**
- * Token usage counters for a single step. All fields are counts of tokens.
- * Cache fields are optional because not all providers expose cache metrics.
- */
-export interface Usage {
- readonly inputTokens: number;
- readonly outputTokens: number;
- readonly cacheReadTokens?: number;
- readonly cacheWriteTokens?: number;
-}
+export type { Usage } from "@dispatch/wire";
/**
* Events a provider yields during a single `stream` call. The kernel consumes
diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts
index 430447c..106dd56 100644
--- a/packages/kernel/src/host/host.test.ts
+++ b/packages/kernel/src/host/host.test.ts
@@ -726,6 +726,117 @@ describe("createHost", () => {
});
});
+ describe("getExtensions", () => {
+ it("returns empty array when no extensions are activated", async () => {
+ const host = createHost([], deps);
+ await host.activate();
+
+ expect(host.getExtensions()).toEqual([]);
+ });
+
+ it("returns manifests of all activated extensions", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b");
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(2);
+ expect(exts.map((e) => e.id)).toContain("ext-a");
+ expect(exts.map((e) => e.id)).toContain("ext-b");
+ });
+
+ it("returns manifests in activation order", async () => {
+ const a = createExtension("a");
+ const b = createExtension("b", { dependsOn: ["a"] });
+ const c = createExtension("c", { dependsOn: ["b"] });
+
+ const host = createHost([c, b, a], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]);
+ });
+
+ it("excludes extensions that failed to activate", async () => {
+ const a = createExtension("good");
+ const b = createExtension("bad", {
+ activate: () => {
+ throw new Error("boom");
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("excludes extensions disabled by apiVersion incompatibility", async () => {
+ const good = createExtension("good");
+ const bad = createExtension("bad", { apiVersion: "^99.0.0" });
+
+ const host = createHost([good, bad], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("returns a frozen array", async () => {
+ const ext = createExtension("ext");
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(Object.isFrozen(exts)).toBe(true);
+ });
+
+ it("HostAPI getExtensions reflects activated extensions after full activation", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b", {
+ dependsOn: ["ext-a"],
+ activate: () => {},
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // Use getHostAPI() to verify the post-activation view
+ const api = host.getHostAPI();
+ const capturedExtsAfter = api.getExtensions();
+
+ expect(capturedExtsAfter).toHaveLength(2);
+ expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]);
+ });
+
+ it("HostAPI getExtensions during activation sees only previously activated", async () => {
+ const seenDuringActivation: string[][] = [];
+
+ const a = createExtension("a", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+ const b = createExtension("b", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // When a activates, activated[] is empty (a hasn't been pushed yet)
+ // When b activates, activated[] has [a] (b hasn't been pushed yet)
+ expect(seenDuringActivation).toEqual([[], ["a"]]);
+ });
+ });
+
describe("DAG errors", () => {
it("throws on missing dependency", () => {
const ext = createExtension("a", { dependsOn: ["missing"] });
diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts
index 2331625..8aa4f78 100644
--- a/packages/kernel/src/host/host.ts
+++ b/packages/kernel/src/host/host.ts
@@ -57,6 +57,7 @@ export interface Host {
readonly getScheduledJobs: () => readonly ScheduledJob[];
readonly getMigrations: () => readonly string[];
readonly getDisabled: () => readonly DisabledExtension[];
+ readonly getExtensions: () => readonly Manifest[];
readonly getHostAPI: () => HostAPI;
}
@@ -150,6 +151,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho
getAuthProvider(id: string) {
return authProviders.get(id);
},
+ getExtensions() {
+ return Object.freeze(activated.map((e) => e.manifest));
+ },
scheduler: {
register(job: ScheduledJob) {
scheduledJobs.push(job);
@@ -213,6 +217,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho
getDisabled() {
return disabled;
},
+ getExtensions() {
+ return Object.freeze(activated.map((e) => e.manifest));
+ },
getHostAPI() {
return buildHostAPI("__host__", { registrationClosed: true });
},
diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json
index 2a3be7e..a882987 100644
--- a/packages/kernel/tsconfig.json
+++ b/packages/kernel/tsconfig.json
@@ -1,5 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"]
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../wire" }]
}
diff --git a/packages/surface-loaded-extensions/package.json b/packages/surface-loaded-extensions/package.json
new file mode 100644
index 0000000..66e2e69
--- /dev/null
+++ b/packages/surface-loaded-extensions/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@dispatch/surface-loaded-extensions",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*"
+ }
+}
diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts
new file mode 100644
index 0000000..abef4b6
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/extension.ts
@@ -0,0 +1,43 @@
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import type { SurfaceProvider } from "@dispatch/surface-registry";
+import { surfaceRegistryHandle } from "@dispatch/surface-registry";
+import { buildLoadedExtensionsSpec } from "./spec.js";
+
+export const manifest: Manifest = {
+ id: "surface-loaded-extensions",
+ name: "Loaded Extensions Surface",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: ["surface-registry"],
+ contributes: { services: [] },
+};
+
+export function createLoadedExtensionsExtension(): Extension {
+ let dispose: (() => void) | undefined;
+
+ return {
+ manifest,
+ activate(host: HostAPI) {
+ const registry = host.getService(surfaceRegistryHandle);
+
+ const provider: SurfaceProvider = {
+ catalogEntry: {
+ id: "loaded-extensions",
+ region: "side",
+ title: "Loaded Extensions",
+ },
+ getSpec() {
+ return buildLoadedExtensionsSpec(host.getExtensions());
+ },
+ invoke() {},
+ };
+
+ dispose = registry.register(provider);
+ },
+ deactivate() {
+ dispose?.();
+ },
+ };
+}
diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts
new file mode 100644
index 0000000..bc10dc5
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/index.ts
@@ -0,0 +1,2 @@
+export { createLoadedExtensionsExtension, manifest } from "./extension.js";
+export { buildLoadedExtensionsSpec } from "./spec.js";
diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts
new file mode 100644
index 0000000..9c1aa6a
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/spec.test.ts
@@ -0,0 +1,94 @@
+import type { Manifest } from "@dispatch/kernel";
+import type { StatField } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import { buildLoadedExtensionsSpec } from "./spec.js";
+
+function fakeManifest(id: string, name: string, version: string): Manifest {
+ return {
+ id,
+ name,
+ version,
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ };
+}
+
+describe("buildLoadedExtensionsSpec", () => {
+ it("returns a count stat of '0' and no extension stats for empty manifests", () => {
+ const spec = buildLoadedExtensionsSpec([]);
+
+ expect(spec.id).toBe("loaded-extensions");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Loaded Extensions");
+ expect(spec.fields).toHaveLength(1);
+ expect(spec.fields[0]).toEqual({
+ kind: "stat",
+ label: "Loaded",
+ value: "0",
+ });
+ });
+
+ it("returns a count stat plus one stat per manifest in order", () => {
+ const manifests = [
+ fakeManifest("alpha", "Alpha", "1.0.0"),
+ fakeManifest("beta", "Beta", "2.3.1"),
+ fakeManifest("gamma", "Gamma", "0.5.0"),
+ ];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect(spec.fields).toHaveLength(4);
+ expect(spec.fields[0]).toEqual({
+ kind: "stat",
+ label: "Loaded",
+ value: "3",
+ });
+ expect(spec.fields[1]).toEqual({
+ kind: "stat",
+ label: "Alpha",
+ value: "1.0.0",
+ });
+ expect(spec.fields[2]).toEqual({
+ kind: "stat",
+ label: "Beta",
+ value: "2.3.1",
+ });
+ expect(spec.fields[3]).toEqual({
+ kind: "stat",
+ label: "Gamma",
+ value: "0.5.0",
+ });
+ });
+
+ it("preserves input order of manifests", () => {
+ const manifests = [
+ fakeManifest("z-last", "Z Last", "1.0.0"),
+ fakeManifest("a-first", "A First", "2.0.0"),
+ ];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect((spec.fields[1] as StatField).label).toBe("Z Last");
+ expect((spec.fields[2] as StatField).label).toBe("A First");
+ });
+
+ it("sets the surface id, region, and title correctly", () => {
+ const spec = buildLoadedExtensionsSpec([]);
+
+ expect(spec.id).toBe("loaded-extensions");
+ expect(spec.region).toBe("side");
+ expect(spec.title).toBe("Loaded Extensions");
+ });
+
+ it("uses manifest.name as label and manifest.version as value", () => {
+ const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")];
+
+ const spec = buildLoadedExtensionsSpec(manifests);
+
+ expect(spec.fields[1]).toEqual({
+ kind: "stat",
+ label: "My Extension",
+ value: "3.2.1",
+ });
+ });
+});
diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts
new file mode 100644
index 0000000..bd3dd56
--- /dev/null
+++ b/packages/surface-loaded-extensions/src/spec.ts
@@ -0,0 +1,25 @@
+import type { Manifest } from "@dispatch/kernel";
+import type { StatField, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * Pure core — builds the SurfaceSpec for the loaded-extensions surface.
+ * Zero I/O, zero ambient state. Decision logic only: input → output.
+ */
+export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec {
+ const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }];
+
+ for (const manifest of manifests) {
+ fields.push({
+ kind: "stat",
+ label: manifest.name,
+ value: manifest.version,
+ });
+ }
+
+ return {
+ id: "loaded-extensions",
+ region: "side",
+ title: "Loaded Extensions",
+ fields,
+ };
+}
diff --git a/packages/surface-loaded-extensions/tsconfig.json b/packages/surface-loaded-extensions/tsconfig.json
new file mode 100644
index 0000000..db257d0
--- /dev/null
+++ b/packages/surface-loaded-extensions/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../ui-contract" },
+ { "path": "../surface-registry" }
+ ]
+}
diff --git a/packages/surface-registry/package.json b/packages/surface-registry/package.json
new file mode 100644
index 0000000..16b0c4c
--- /dev/null
+++ b/packages/surface-registry/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@dispatch/surface-registry",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*"
+ }
+}
diff --git a/packages/surface-registry/src/extension.ts b/packages/surface-registry/src/extension.ts
new file mode 100644
index 0000000..6d0ce22
--- /dev/null
+++ b/packages/surface-registry/src/extension.ts
@@ -0,0 +1,23 @@
+import type { Extension, Manifest } from "@dispatch/kernel";
+import { createSurfaceRegistry } from "./registry.js";
+import { surfaceRegistryHandle } from "./service.js";
+
+export const manifest: Manifest = {
+ id: "surface-registry",
+ name: "Surface Registry",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ contributes: { services: ["surface-registry/registry"] },
+};
+
+export function createSurfaceRegistryExtension(): Extension {
+ return {
+ manifest,
+ activate(host) {
+ const registry = createSurfaceRegistry();
+ host.provideService(surfaceRegistryHandle, registry);
+ },
+ };
+}
diff --git a/packages/surface-registry/src/index.ts b/packages/surface-registry/src/index.ts
new file mode 100644
index 0000000..cdfcf7e
--- /dev/null
+++ b/packages/surface-registry/src/index.ts
@@ -0,0 +1,4 @@
+export { createSurfaceRegistryExtension, manifest } from "./extension.js";
+export type { SurfaceProvider, SurfaceRegistry } from "./registry.js";
+export { createSurfaceRegistry } from "./registry.js";
+export { surfaceRegistryHandle } from "./service.js";
diff --git a/packages/surface-registry/src/registry.test.ts b/packages/surface-registry/src/registry.test.ts
new file mode 100644
index 0000000..c47c979
--- /dev/null
+++ b/packages/surface-registry/src/registry.test.ts
@@ -0,0 +1,122 @@
+import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import type { SurfaceProvider } from "./registry.js";
+import { createSurfaceRegistry } from "./registry.js";
+
+function fakeProvider(id: string, title?: string): SurfaceProvider {
+ const catalogEntry: SurfaceCatalogEntry = {
+ id,
+ region: "default",
+ title: title ?? `Surface ${id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(): SurfaceSpec {
+ return {
+ id,
+ region: "default",
+ title: catalogEntry.title,
+ fields: [],
+ };
+ },
+ invoke() {},
+ };
+}
+
+describe("createSurfaceRegistry", () => {
+ describe("register + getCatalog", () => {
+ it("returns the entry after registration", () => {
+ const registry = createSurfaceRegistry();
+ registry.register(fakeProvider("a", "Surface A"));
+
+ const catalog = registry.getCatalog();
+ expect(catalog).toHaveLength(1);
+ expect(catalog[0]).toEqual({
+ id: "a",
+ region: "default",
+ title: "Surface A",
+ });
+ });
+
+ it("returns entries for multiple providers", () => {
+ const registry = createSurfaceRegistry();
+ registry.register(fakeProvider("a"));
+ registry.register(fakeProvider("b"));
+
+ const catalog = registry.getCatalog();
+ expect(catalog).toHaveLength(2);
+ expect(catalog.map((e) => e.id)).toEqual(["a", "b"]);
+ });
+ });
+
+ describe("getSurface", () => {
+ it("returns the provider for a known id", () => {
+ const registry = createSurfaceRegistry();
+ const provider = fakeProvider("x");
+ registry.register(provider);
+
+ expect(registry.getSurface("x")).toBe(provider);
+ });
+
+ it("returns undefined for an unknown id", () => {
+ const registry = createSurfaceRegistry();
+ expect(registry.getSurface("nonexistent")).toBeUndefined();
+ });
+ });
+
+ describe("disposer", () => {
+ it("removes the provider from catalog and lookup", () => {
+ const registry = createSurfaceRegistry();
+ const dispose = registry.register(fakeProvider("a"));
+
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getSurface("a")).toBeDefined();
+
+ dispose();
+
+ expect(registry.getCatalog()).toHaveLength(0);
+ expect(registry.getSurface("a")).toBeUndefined();
+ });
+
+ it("is idempotent — calling dispose twice is safe", () => {
+ const registry = createSurfaceRegistry();
+ const dispose = registry.register(fakeProvider("a"));
+
+ dispose();
+ dispose();
+
+ expect(registry.getCatalog()).toHaveLength(0);
+ });
+
+ it("does not remove a replacement provider with the same id", () => {
+ const registry = createSurfaceRegistry();
+ const first = fakeProvider("a", "First");
+ const second = fakeProvider("a", "Second");
+
+ const disposeFirst = registry.register(first);
+ registry.register(second);
+
+ disposeFirst();
+
+ // The second provider should still be registered
+ expect(registry.getSurface("a")).toBe(second);
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getCatalog()[0]?.title).toBe("Second");
+ });
+ });
+
+ describe("duplicate-id behavior (last-wins)", () => {
+ it("replaces an existing provider when registering the same id", () => {
+ const registry = createSurfaceRegistry();
+ const first = fakeProvider("a", "First");
+ const second = fakeProvider("a", "Second");
+
+ registry.register(first);
+ registry.register(second);
+
+ expect(registry.getSurface("a")).toBe(second);
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getCatalog()[0]?.title).toBe("Second");
+ });
+ });
+});
diff --git a/packages/surface-registry/src/registry.ts b/packages/surface-registry/src/registry.ts
new file mode 100644
index 0000000..b1c8116
--- /dev/null
+++ b/packages/surface-registry/src/registry.ts
@@ -0,0 +1,80 @@
+import type { SurfaceCatalog, SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * What a surface-contributing extension registers with the surface registry.
+ * Each provider owns one surface identified by its catalog entry id.
+ */
+export interface SurfaceProvider {
+ /** Discovery metadata for the surface catalog. */
+ readonly catalogEntry: SurfaceCatalogEntry;
+
+ /** Build the current surface spec (may be async for dynamic surfaces). */
+ getSpec(): SurfaceSpec | Promise<SurfaceSpec>;
+
+ /** Run a backend action by id with an optional payload. */
+ invoke(actionId: string, payload?: unknown): void | Promise<void>;
+
+ /**
+ * Optional: subscribe to spec changes. Returns an unsubscribe disposer.
+ * When the spec changes, the caller should re-fetch via getSpec() and push.
+ */
+ subscribe?(onChange: () => void): () => void;
+}
+
+/**
+ * The surface registry service — the interface other extensions obtain via
+ * `host.getService(surfaceRegistryHandle)`.
+ */
+export interface SurfaceRegistry {
+ /**
+ * Register a surface provider. Returns an unregister disposer.
+ * If a provider with the same id is already registered, the new one
+ * replaces it (last-wins semantics).
+ */
+ register(provider: SurfaceProvider): () => void;
+
+ /** Return discovery metadata for all currently registered providers. */
+ getCatalog(): SurfaceCatalog;
+
+ /** Look up a provider by its surface id. */
+ getSurface(id: string): SurfaceProvider | undefined;
+}
+
+/**
+ * Create a pure in-memory surface registry. No I/O, no ambient state —
+ * the decision logic is a plain Map behind the SurfaceRegistry interface.
+ */
+export function createSurfaceRegistry(): SurfaceRegistry {
+ const providers = new Map<string, SurfaceProvider>();
+
+ return {
+ register(provider: SurfaceProvider): () => void {
+ const id = provider.catalogEntry.id;
+ providers.set(id, provider);
+
+ let disposed = false;
+ return () => {
+ if (!disposed) {
+ disposed = true;
+ // Only delete if the current entry is still this provider
+ // (another register with the same id may have replaced it).
+ if (providers.get(id) === provider) {
+ providers.delete(id);
+ }
+ }
+ };
+ },
+
+ getCatalog(): SurfaceCatalog {
+ const entries: SurfaceCatalogEntry[] = [];
+ for (const provider of providers.values()) {
+ entries.push(provider.catalogEntry);
+ }
+ return entries;
+ },
+
+ getSurface(id: string): SurfaceProvider | undefined {
+ return providers.get(id);
+ },
+ };
+}
diff --git a/packages/surface-registry/src/service.ts b/packages/surface-registry/src/service.ts
new file mode 100644
index 0000000..a43c155
--- /dev/null
+++ b/packages/surface-registry/src/service.ts
@@ -0,0 +1,4 @@
+import { defineService } from "@dispatch/kernel";
+import type { SurfaceRegistry } from "./registry.js";
+
+export const surfaceRegistryHandle = defineService<SurfaceRegistry>("surface-registry/registry");
diff --git a/packages/surface-registry/tsconfig.json b/packages/surface-registry/tsconfig.json
new file mode 100644
index 0000000..e430ba9
--- /dev/null
+++ b/packages/surface-registry/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }]
+}
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 83c8a71..2af6a73 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -6,6 +6,6 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
- "@dispatch/kernel": "workspace:*"
+ "@dispatch/wire": "workspace:*"
}
}
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 5f16d8a..7d3996a 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -12,7 +12,7 @@
* union, re-exported here so a client has one import for the whole wire.
*/
-export type { AgentEvent } from "@dispatch/kernel";
+export type { AgentEvent } from "@dispatch/wire";
/**
* Request body for `POST /chat` (sent as JSON).
diff --git a/packages/transport-contract/tsconfig.json b/packages/transport-contract/tsconfig.json
index ff99a43..a882987 100644
--- a/packages/transport-contract/tsconfig.json
+++ b/packages/transport-contract/tsconfig.json
@@ -2,5 +2,5 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
"include": ["src/**/*.ts"],
- "references": [{ "path": "../kernel" }]
+ "references": [{ "path": "../wire" }]
}
diff --git a/packages/transport-ws/package.json b/packages/transport-ws/package.json
new file mode 100644
index 0000000..dab8ebc
--- /dev/null
+++ b/packages/transport-ws/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@dispatch/transport-ws",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*"
+ }
+}
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
new file mode 100644
index 0000000..a18aefa
--- /dev/null
+++ b/packages/transport-ws/src/extension.ts
@@ -0,0 +1,161 @@
+/**
+ * Shell — thin imperative layer that owns the Bun.serve WebSocket server.
+ *
+ * All decision logic lives in router.ts (pure, unit-tested).
+ * This file handles I/O only: WS accept, JSON parse/stringify,
+ * provider.subscribe wiring, server lifecycle.
+ */
+
+import type { Extension, HostAPI } from "@dispatch/kernel";
+import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
+import { surfaceRegistryHandle } from "@dispatch/surface-registry";
+import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
+import { manifest } from "./manifest.js";
+import { catalogMessage, routeClientMessage } from "./router.js";
+
+/** Active provider subscriptions for a single WS connection. */
+interface ConnectionState {
+ readonly subs: Set<string>;
+ readonly providerDisposers: Map<string, () => void>;
+}
+
+type Ws = Bun.ServerWebSocket<ConnectionState>;
+
+export function createTransportWsExtension(): Extension {
+ let server: ReturnType<typeof Bun.serve<ConnectionState>> | undefined;
+
+ return {
+ manifest,
+ async activate(host: HostAPI) {
+ const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle);
+ const logger = host.logger;
+ const port = host.config.get<number>("surfaceWsPort") ?? 24205;
+
+ function send(ws: Ws, msg: SurfaceServerMessage): void {
+ try {
+ ws.send(JSON.stringify(msg));
+ } catch {
+ // Connection may have been dropped; swallow.
+ }
+ }
+
+ function subscribeToProvider(
+ ws: Ws,
+ provider: SurfaceProvider,
+ surfaceId: string,
+ state: ConnectionState,
+ ): void {
+ if (!provider.subscribe || state.providerDisposers.has(surfaceId)) {
+ return;
+ }
+ const dispose = provider.subscribe(() => {
+ try {
+ const spec = provider.getSpec();
+ if (spec instanceof Promise) {
+ spec
+ .then((s) => send(ws, { type: "update", update: { surfaceId, spec: s } }))
+ .catch(() => {});
+ } else {
+ send(ws, { type: "update", update: { surfaceId, spec } });
+ }
+ } catch {
+ // Provider threw — log but don't kill the connection.
+ }
+ });
+ state.providerDisposers.set(surfaceId, dispose);
+ }
+
+ function unsubscribeFromProvider(state: ConnectionState, surfaceId: string): void {
+ const dispose = state.providerDisposers.get(surfaceId);
+ if (dispose) {
+ dispose();
+ state.providerDisposers.delete(surfaceId);
+ }
+ }
+
+ server = Bun.serve<ConnectionState>({
+ port,
+ fetch(req, srv) {
+ const initial: ConnectionState = {
+ subs: new Set(),
+ providerDisposers: new Map(),
+ };
+ if (srv.upgrade(req, { data: initial })) return;
+ return new Response("expected websocket", { status: 426 });
+ },
+ websocket: {
+ open(ws) {
+ send(ws, catalogMessage(registry));
+ },
+
+ message(ws, message) {
+ const state = ws.data;
+ if (!state) return;
+
+ let parsed: SurfaceClientMessage;
+ try {
+ parsed = JSON.parse(String(message)) as SurfaceClientMessage;
+ } catch {
+ send(ws, { type: "error", message: "Invalid JSON" });
+ return;
+ }
+
+ const result = routeClientMessage(registry, state.subs, parsed);
+
+ // Apply sub change.
+ if (result.subChange) {
+ if (result.subChange.op === "add") {
+ state.subs.add(result.subChange.surfaceId);
+ const provider = registry.getSurface(result.subChange.surfaceId);
+ if (provider) {
+ subscribeToProvider(ws, provider, result.subChange.surfaceId, state);
+ }
+ } else {
+ state.subs.delete(result.subChange.surfaceId);
+ unsubscribeFromProvider(state, result.subChange.surfaceId);
+ }
+ }
+
+ // Send replies.
+ for (const reply of result.replies) {
+ send(ws, reply);
+ }
+
+ // Perform invoke if signalled.
+ if (result.invoke) {
+ const provider = registry.getSurface(result.invoke.surfaceId);
+ if (provider) {
+ try {
+ const r = provider.invoke(result.invoke.actionId, result.invoke.payload);
+ if (r instanceof Promise) {
+ r.catch(() => {});
+ }
+ } catch {
+ // Provider threw on invoke — log but don't kill the connection.
+ }
+ }
+ }
+ },
+
+ close(ws) {
+ const state = ws.data;
+ if (state) {
+ for (const dispose of state.providerDisposers.values()) {
+ dispose();
+ }
+ }
+ },
+ },
+ });
+
+ logger.info?.("transport-ws: surface WebSocket listening", { port });
+ },
+
+ deactivate() {
+ if (server) {
+ server.stop();
+ server = undefined;
+ }
+ },
+ };
+}
diff --git a/packages/transport-ws/src/index.ts b/packages/transport-ws/src/index.ts
new file mode 100644
index 0000000..a93611f
--- /dev/null
+++ b/packages/transport-ws/src/index.ts
@@ -0,0 +1,4 @@
+export { createTransportWsExtension } from "./extension.js";
+export { manifest } from "./manifest.js";
+export type { RouteResult } from "./router.js";
+export { catalogMessage, routeClientMessage } from "./router.js";
diff --git a/packages/transport-ws/src/manifest.ts b/packages/transport-ws/src/manifest.ts
new file mode 100644
index 0000000..b0612e2
--- /dev/null
+++ b/packages/transport-ws/src/manifest.ts
@@ -0,0 +1,13 @@
+import type { Manifest } from "@dispatch/kernel";
+
+export const manifest: Manifest = {
+ id: "transport-ws",
+ name: "Transport WebSocket",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ dependsOn: ["surface-registry"],
+ capabilities: { network: true },
+ contributes: { routes: ["/ws/surfaces"] },
+ activation: "eager",
+};
diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts
new file mode 100644
index 0000000..83496f3
--- /dev/null
+++ b/packages/transport-ws/src/router.test.ts
@@ -0,0 +1,203 @@
+import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
+import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import { catalogMessage, routeClientMessage } from "./router.js";
+
+// ── Fake in-memory registry (no mocks — just a plain implementation) ────────
+
+function fakeProvider(id: string, title?: string, actions?: readonly string[]): SurfaceProvider {
+ const catalogEntry: SurfaceCatalogEntry = {
+ id,
+ region: "default",
+ title: title ?? `Surface ${id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(): SurfaceSpec {
+ return {
+ id,
+ region: "default",
+ title: catalogEntry.title,
+ fields:
+ actions?.map((a) => ({
+ kind: "button" as const,
+ label: a,
+ action: { actionId: a },
+ })) ?? [],
+ };
+ },
+ invoke(_actionId: string, _payload?: unknown) {},
+ };
+}
+
+function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry {
+ const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
+ return {
+ register(_provider: SurfaceProvider) {
+ return () => {};
+ },
+ getCatalog() {
+ return [...map.values()].map((p) => p.catalogEntry);
+ },
+ getSurface(id: string) {
+ return map.get(id);
+ },
+ };
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────────
+
+describe("routeClientMessage", () => {
+ describe("subscribe", () => {
+ it("replies with `surface` and tracks the subscription", () => {
+ const provider = fakeProvider("a", "Surface A");
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "surface",
+ spec: {
+ id: "a",
+ region: "default",
+ title: "Surface A",
+ fields: [],
+ },
+ });
+ expect(result.subChange).toEqual({ op: "add", surfaceId: "a" });
+ });
+
+ it("is idempotent — subscribing twice does not duplicate the subChange", () => {
+ const provider = fakeProvider("a");
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>(["a"]); // already subscribed
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]?.type).toBe("surface");
+ expect(result.subChange).toBeUndefined();
+ });
+
+ it("returns `error` for an unknown surface id", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "nonexistent",
+ });
+
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "error",
+ surfaceId: "nonexistent",
+ message: "Unknown surface: nonexistent",
+ });
+ expect(result.subChange).toBeUndefined();
+ });
+ });
+
+ describe("unsubscribe", () => {
+ it("emits a remove subChange and no replies", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>(["a"]);
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "unsubscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.replies).toHaveLength(0);
+ expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
+ });
+
+ it("emits remove even if not currently subscribed (idempotent)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "unsubscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.replies).toHaveLength(0);
+ expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
+ });
+ });
+
+ describe("invoke", () => {
+ it("signals the invoke effect for a known surface", () => {
+ const provider = fakeProvider("a", "Surface A", ["toggle"]);
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "invoke",
+ surfaceId: "a",
+ actionId: "toggle",
+ payload: true,
+ });
+
+ expect(result.replies).toHaveLength(0);
+ expect(result.invoke).toEqual({
+ surfaceId: "a",
+ actionId: "toggle",
+ payload: true,
+ });
+ });
+
+ it("returns `error` for an unknown surface id", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "invoke",
+ surfaceId: "nonexistent",
+ actionId: "toggle",
+ });
+
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "error",
+ surfaceId: "nonexistent",
+ message: "Unknown surface: nonexistent",
+ });
+ expect(result.invoke).toBeUndefined();
+ });
+ });
+});
+
+describe("catalogMessage", () => {
+ it("returns the catalog from the registry", () => {
+ const providerA = fakeProvider("a", "Surface A");
+ const providerB = fakeProvider("b", "Surface B");
+ const registry = fakeRegistry([providerA, providerB]);
+
+ const msg = catalogMessage(registry);
+
+ expect(msg).toEqual({
+ type: "catalog",
+ catalog: [
+ { id: "a", region: "default", title: "Surface A" },
+ { id: "b", region: "default", title: "Surface B" },
+ ],
+ });
+ });
+
+ it("returns an empty catalog when no providers are registered", () => {
+ const registry = fakeRegistry([]);
+
+ const msg = catalogMessage(registry);
+
+ expect(msg).toEqual({ type: "catalog", catalog: [] });
+ });
+});
diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts
new file mode 100644
index 0000000..f9a7a82
--- /dev/null
+++ b/packages/transport-ws/src/router.ts
@@ -0,0 +1,116 @@
+/**
+ * Pure core — routes a client WS message into an effect description.
+ *
+ * Zero I/O, zero ambient state. Every function is `input → output`:
+ * it decides what to do but does NOT do it. The shell (extension.ts)
+ * interprets the result: sends WS messages, mutates connSubs, calls
+ * provider.invoke.
+ */
+
+import type { SurfaceRegistry } from "@dispatch/surface-registry";
+import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
+
+// ── Result types ────────────────────────────────────────────────────────────
+
+/** The effect a single client message should produce. */
+export interface RouteResult {
+ /** Server messages to send back to this connection. */
+ readonly replies: readonly SurfaceServerMessage[];
+ /** Whether to add or remove the surface id from connSubs. */
+ readonly subChange?: { readonly op: "add" | "remove"; readonly surfaceId: string };
+ /** If set, the shell must call `provider.invoke(actionId, payload)`. */
+ readonly invoke?: {
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+ };
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+/** Build the catalog `SurfaceServerMessage` from the registry. */
+export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage {
+ return { type: "catalog", catalog: registry.getCatalog() };
+}
+
+// ── Router ──────────────────────────────────────────────────────────────────
+
+/**
+ * Route a single client message into a pure effect description.
+ *
+ * @param registry The surface registry (looked up once, injected).
+ * @param connSubs This connection's current subscribed surface ids.
+ * @param msg The parsed client message.
+ */
+export function routeClientMessage(
+ registry: SurfaceRegistry,
+ connSubs: ReadonlySet<string>,
+ msg: SurfaceClientMessage,
+): RouteResult {
+ switch (msg.type) {
+ case "subscribe":
+ return handleSubscribe(registry, connSubs, msg.surfaceId);
+ case "unsubscribe":
+ return handleUnsubscribe(msg.surfaceId);
+ case "invoke":
+ return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload);
+ }
+}
+
+// ── Per-message handlers ────────────────────────────────────────────────────
+
+function handleSubscribe(
+ registry: SurfaceRegistry,
+ connSubs: ReadonlySet<string>,
+ surfaceId: string,
+): RouteResult {
+ const provider = registry.getSurface(surfaceId);
+ if (!provider) {
+ return {
+ replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
+ };
+ }
+
+ const spec = provider.getSpec();
+
+ // getSpec may be sync or async — the pure core treats it as a value the
+ // shell will resolve. We return the spec directly (it's a SurfaceSpec).
+ // If it's a Promise the shell awaits it; if it's sync it's already the value.
+ // For the pure core we just pass it through — the shell handles the resolution.
+ const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec;
+
+ const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [
+ { type: "surface", spec: specValue },
+ ];
+
+ // Idempotent: only emit subChange if not already subscribed.
+ if (!connSubs.has(surfaceId)) {
+ return { replies, subChange: { op: "add", surfaceId } };
+ }
+ return { replies };
+}
+
+function handleUnsubscribe(surfaceId: string): RouteResult {
+ return {
+ replies: [],
+ subChange: { op: "remove", surfaceId },
+ };
+}
+
+function handleInvoke(
+ registry: SurfaceRegistry,
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+): RouteResult {
+ const provider = registry.getSurface(surfaceId);
+ if (!provider) {
+ return {
+ replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
+ };
+ }
+ return {
+ replies: [],
+ invoke: { surfaceId, actionId, payload },
+ };
+}
diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts
new file mode 100644
index 0000000..d51eb72
--- /dev/null
+++ b/packages/transport-ws/src/server.bun.test.ts
@@ -0,0 +1,195 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
+import type {
+ SurfaceCatalogEntry,
+ SurfaceClientMessage,
+ SurfaceServerMessage,
+ SurfaceSpec,
+} from "@dispatch/ui-contract";
+import { catalogMessage, routeClientMessage } from "./router.js";
+
+// ── Fake registry (same pattern as router.test.ts) ──────────────────────────
+
+function fakeProvider(id: string, title?: string): SurfaceProvider {
+ const catalogEntry: SurfaceCatalogEntry = {
+ id,
+ region: "default",
+ title: title ?? `Surface ${id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(): SurfaceSpec {
+ return {
+ id,
+ region: "default",
+ title: catalogEntry.title,
+ fields: [],
+ };
+ },
+ invoke(_actionId: string, _payload?: unknown) {},
+ };
+}
+
+function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry {
+ const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
+ return {
+ register(_provider: SurfaceProvider) {
+ return () => {};
+ },
+ getCatalog() {
+ return [...map.values()].map((p) => p.catalogEntry);
+ },
+ getSurface(id: string) {
+ return map.get(id);
+ },
+ };
+}
+
+// ── Per-connection state (mirrors extension.ts) ─────────────────────────────
+
+interface ConnectionState {
+ readonly subs: Set<string>;
+ readonly providerDisposers: Map<string, () => void>;
+}
+
+// ── Server helper ───────────────────────────────────────────────────────────
+
+function startServer(registry: SurfaceRegistry, port = 0) {
+ return Bun.serve<ConnectionState>({
+ port,
+ fetch(req, srv) {
+ const initial: ConnectionState = {
+ subs: new Set(),
+ providerDisposers: new Map(),
+ };
+ if (srv.upgrade(req, { data: initial })) return;
+ return new Response("expected websocket", { status: 426 });
+ },
+ websocket: {
+ open(ws) {
+ ws.send(JSON.stringify(catalogMessage(registry)));
+ },
+
+ message(ws, raw) {
+ const state = ws.data;
+ if (!state) return;
+
+ let parsed: SurfaceClientMessage;
+ try {
+ parsed = JSON.parse(String(raw)) as SurfaceClientMessage;
+ } catch {
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
+ return;
+ }
+
+ const result = routeClientMessage(registry, state.subs, parsed);
+
+ if (result.subChange) {
+ if (result.subChange.op === "add") {
+ state.subs.add(result.subChange.surfaceId);
+ } else {
+ state.subs.delete(result.subChange.surfaceId);
+ }
+ }
+
+ for (const reply of result.replies) {
+ ws.send(JSON.stringify(reply));
+ }
+ },
+
+ close(ws) {
+ const state = ws.data;
+ if (state) {
+ for (const dispose of state.providerDisposers.values()) {
+ dispose();
+ }
+ }
+ },
+ },
+ });
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+function waitForMessage(ws: WebSocket): Promise<SurfaceServerMessage> {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000);
+ function handler(ev: MessageEvent) {
+ clearTimeout(timeout);
+ ws.removeEventListener("message", handler);
+ resolve(JSON.parse(ev.data as string) as SurfaceServerMessage);
+ }
+ ws.addEventListener("message", handler);
+ });
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────────
+
+describe("Bun.serve WebSocket server", () => {
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
+
+ beforeEach(() => {
+ const provider = fakeProvider("demo", "Demo Surface");
+ const registry = fakeRegistry([provider]);
+ server = startServer(registry);
+ port = server.port as number;
+ });
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("performs WebSocket upgrade (returns 101)", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ const msg = await waitForMessage(ws);
+ expect(msg.type).toBe("catalog");
+ ws.close();
+ });
+
+ test("sends catalog on open", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ const msg = await waitForMessage(ws);
+ expect(msg).toEqual({
+ type: "catalog",
+ catalog: [{ id: "demo", region: "default", title: "Demo Surface" }],
+ });
+ ws.close();
+ });
+
+ test("subscribe returns surface spec", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" }));
+ const msg = await waitForMessage(ws);
+
+ expect(msg.type).toBe("surface");
+ if (msg.type === "surface") {
+ expect(msg.spec.id).toBe("demo");
+ expect(msg.spec.title).toBe("Demo Surface");
+ }
+ ws.close();
+ });
+
+ test("subscribe to unknown surface returns error", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" }));
+ const msg = await waitForMessage(ws);
+
+ expect(msg).toEqual({
+ type: "error",
+ surfaceId: "nope",
+ message: "Unknown surface: nope",
+ });
+ ws.close();
+ });
+
+ test("non-WebSocket request returns 426", async () => {
+ const res = await fetch(`http://localhost:${port}/`);
+ expect(res.status).toBe(426);
+ expect(await res.text()).toBe("expected websocket");
+ });
+});
diff --git a/packages/transport-ws/tsconfig.json b/packages/transport-ws/tsconfig.json
new file mode 100644
index 0000000..102c8f0
--- /dev/null
+++ b/packages/transport-ws/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../surface-registry" },
+ { "path": "../ui-contract" }
+ ]
+}
diff --git a/packages/ui-contract/package.json b/packages/ui-contract/package.json
new file mode 100644
index 0000000..e1f4c35
--- /dev/null
+++ b/packages/ui-contract/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@dispatch/ui-contract",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts"
+}
diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts
new file mode 100644
index 0000000..ea0fc26
--- /dev/null
+++ b/packages/ui-contract/src/index.ts
@@ -0,0 +1,198 @@
+/**
+ * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
+ *
+ * A SURFACE is a "data transportation surface": a typed description of what data an
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI. It
+ * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering-
+ * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface
+ * in its own idiom, so swapping or adding a client is a zero-backend-change event.
+ * See `notes/frontend-design.md` §4.
+ *
+ * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies,
+ * so a separate client repo can depend on JUST this contract.
+ */
+
+/**
+ * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS
+ * instruction. A client maps a region to its own idiom; an unknown region falls back
+ * to the client's default placement. Deliberately left open (a `string`): region
+ * names are not finalized (the old-Dispatch "view" sidebar UX will be revisited).
+ */
+export type Region = string;
+
+/**
+ * A typed reference to a backend action a field can invoke. The client posts it back
+ * (with a payload) to `POST /surfaces/:surfaceId/actions/:actionId`; the surface id
+ * comes from context. (Backend-side this maps to a `command` today — a future review
+ * may unify `command` → `action`; see `notes/restructure-plan.md` §8.)
+ */
+export interface ActionRef {
+ readonly actionId: string;
+}
+
+/** One selectable option in a `selector` field. */
+export interface SurfaceOption {
+ readonly value: string;
+ readonly label: string;
+}
+
+/**
+ * A field within a surface — a SEMANTIC value, not a widget. `kind` is the
+ * discriminant a client switches on to pick a renderer. Names are training-baked
+ * hints; the contract is the data shape.
+ */
+export type SurfaceField =
+ | ToggleField
+ | ProgressField
+ | SelectorField
+ | StatField
+ | ButtonField
+ | CustomField;
+
+/** A boolean setting plus the action that flips it. */
+export interface ToggleField {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */
+export interface ProgressField {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** An enum choice: the current value, the options, and the action that sets it. */
+export interface SelectorField {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** A read-only labelled scalar readout. */
+export interface StatField {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** A labelled action trigger. */
+export interface ButtonField {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/**
+ * The escape hatch (isolation guardrail 2): data that fits no semantic field kind.
+ * Carries an opaque `payload` + a `rendererId`; clients WITH a renderer for that id
+ * show it, others GRACEFULLY SKIP. Keep rare — and the owning extension should export
+ * a typed payload type so its bespoke renderer narrows `payload` via a typed symbol
+ * (not a blind `unknown`).
+ */
+export interface CustomField {
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
+}
+
+/**
+ * A surface: an ordered set of fields mounted in a region, with a title. The atomic
+ * unit a backend extension contributes and a client renders.
+ */
+export interface SurfaceSpec {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+ readonly fields: readonly SurfaceField[];
+}
+
+/**
+ * A surface-catalog entry — discovery metadata only (no field data). Returned by
+ * `GET /surfaces`; parallels the model catalog. The full spec + live values come from
+ * `GET /surfaces/:id`.
+ */
+export interface SurfaceCatalogEntry {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+}
+
+/** The surface catalog: the list of available surfaces a client can choose to show. */
+export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
+
+/**
+ * A live update for a subscribed surface (pushed over the WS channel — §5). v1
+ * carries the full new spec (the simplest "patch"); granular field-level patches are
+ * deferred until a real surface needs them (P4).
+ */
+export interface SurfaceUpdate {
+ readonly surfaceId: string;
+ readonly spec: SurfaceSpec;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Surface WebSocket protocol — the typed message envelopes the surface channel
+// carries. The carrier (a WebSocket) is INJECTED; these are the payloads both the
+// server (transport-ws) and any client serialize/deserialize. Slice 1 is
+// surfaces-only; chat deltas join this channel in a later slice (a separate union).
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** A client → server message on the surface channel. */
+export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+
+/** Begin receiving live updates for a surface (server replies with `surface`, then `update`s). */
+export interface SubscribeMessage {
+ readonly type: "subscribe";
+ readonly surfaceId: string;
+}
+
+/** Stop receiving updates for a surface. */
+export interface UnsubscribeMessage {
+ readonly type: "unsubscribe";
+ readonly surfaceId: string;
+}
+
+/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */
+export interface InvokeMessage {
+ readonly type: "invoke";
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+}
+
+/** A server → client message on the surface channel. */
+export type SurfaceServerMessage =
+ | CatalogMessage
+ | SurfaceMessage
+ | SurfaceUpdateMessage
+ | SurfaceErrorMessage;
+
+/** The current surface catalog (sent on connect and whenever it changes). */
+export interface CatalogMessage {
+ readonly type: "catalog";
+ readonly catalog: SurfaceCatalog;
+}
+
+/** The full current spec for a surface the client just subscribed to. */
+export interface SurfaceMessage {
+ readonly type: "surface";
+ readonly spec: SurfaceSpec;
+}
+
+/** A live update for a subscribed surface. */
+export interface SurfaceUpdateMessage {
+ readonly type: "update";
+ readonly update: SurfaceUpdate;
+}
+
+/** A surface-scoped error (e.g. unknown surface id, invoke failed). */
+export interface SurfaceErrorMessage {
+ readonly type: "error";
+ readonly surfaceId?: string;
+ readonly message: string;
+}
diff --git a/packages/ui-contract/tsconfig.json b/packages/ui-contract/tsconfig.json
new file mode 100644
index 0000000..2a3be7e
--- /dev/null
+++ b/packages/ui-contract/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/wire/package.json b/packages/wire/package.json
new file mode 100644
index 0000000..2893e79
--- /dev/null
+++ b/packages/wire/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@dispatch/wire",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts"
+}
diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts
new file mode 100644
index 0000000..d2ea341
--- /dev/null
+++ b/packages/wire/src/index.ts
@@ -0,0 +1,221 @@
+/**
+ * @dispatch/wire — pure wire types shared by the kernel, the transport
+ * contract, and out-of-repo clients (the web frontend).
+ *
+ * Types ONLY: zero runtime, zero `@dispatch/*` dependencies, so a client can
+ * depend on the wire without pulling the kernel runtime.
+ */
+
+// ─── Conversation model ─────────────────────────────────────────────────────
+
+/** Who produced a message. */
+export type Role = "system" | "user" | "assistant" | "tool";
+
+/** Opaque identifier for a turn (one user→assistant cycle). */
+export type TurnId = string & { readonly __brand: "TurnId" };
+
+/** Opaque identifier for a step (one LLM round-trip within a turn). */
+export type StepId = string & { readonly __brand: "StepId" };
+
+/**
+ * A chunk is one ordered piece of a message — the atomic unit of the
+ * append-only conversation log. Discriminated by `type`.
+ */
+export type Chunk =
+ | TextChunk
+ | ThinkingChunk
+ | ToolCallChunk
+ | ToolResultChunk
+ | ErrorChunk
+ | SystemChunk;
+
+/** A piece of plain text content from the assistant or user. */
+export interface TextChunk {
+ readonly type: "text";
+ readonly text: string;
+}
+
+/** A piece of model reasoning / thinking content (e.g. extended thinking). */
+export interface ThinkingChunk {
+ readonly type: "thinking";
+ readonly text: string;
+}
+
+/**
+ * A model's request to run a tool. The kernel routes by `name`; the tool
+ * implementation never sees this directly — it receives parsed `input` via
+ * `ToolContract.execute`.
+ */
+export interface ToolCallChunk {
+ readonly type: "tool-call";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly input: unknown;
+}
+
+/**
+ * The result of a tool execution, attributed to the originating tool-call id.
+ * The kernel guarantees every tool-call chunk gets exactly one result chunk
+ * (synthesized if interrupted — see reconcile).
+ */
+export interface ToolResultChunk {
+ readonly type: "tool-result";
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly content: string;
+ readonly isError: boolean;
+}
+
+/** An error that occurred during generation or tool dispatch. */
+export interface ErrorChunk {
+ readonly type: "error";
+ readonly message: string;
+ readonly code?: string;
+}
+
+/**
+ * A system-injected message (e.g. system prompt, context assembly output).
+ * Kept distinct from text so the log records provenance.
+ */
+export interface SystemChunk {
+ readonly type: "system";
+ readonly text: string;
+}
+
+/**
+ * A chat message: a role plus an ordered sequence of chunks. Messages are the
+ * unit passed to and from the provider; chunks are the unit persisted and
+ * rendered.
+ */
+export interface ChatMessage {
+ readonly role: Role;
+ readonly chunks: readonly Chunk[];
+}
+
+// ─── Usage ──────────────────────────────────────────────────────────────────
+
+/**
+ * Token usage counters for a single step. All fields are counts of tokens.
+ * Cache fields are optional because not all providers expose cache metrics.
+ */
+export interface Usage {
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens?: number;
+ readonly cacheWriteTokens?: number;
+}
+
+// ─── Outward events ─────────────────────────────────────────────────────────
+
+/**
+ * The union of all events the runtime emits outward during a turn.
+ * Consumers (transport, persistence, notifications) pattern-match on `type`.
+ */
+export type AgentEvent =
+ | StatusEvent
+ | TurnStartEvent
+ | TurnTextDeltaEvent
+ | TurnReasoningDeltaEvent
+ | TurnToolCallEvent
+ | TurnToolResultEvent
+ | TurnToolOutputEvent
+ | TurnUsageEvent
+ | TurnErrorEvent
+ | TurnDoneEvent
+ | TurnSealedEvent;
+
+/** Status change for a conversation (e.g. idle → running). */
+export interface StatusEvent {
+ readonly type: "status";
+ readonly conversationId: string;
+ readonly status: string;
+}
+
+/** A turn has begun. */
+export interface TurnStartEvent {
+ readonly type: "turn-start";
+ readonly conversationId: string;
+ readonly turnId: string;
+}
+
+/** Incremental text content from the model during a turn. */
+export interface TurnTextDeltaEvent {
+ readonly type: "text-delta";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly delta: string;
+}
+
+/** Incremental reasoning / thinking content during a turn. */
+export interface TurnReasoningDeltaEvent {
+ readonly type: "reasoning-delta";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly delta: string;
+}
+
+/** The model has requested a tool to be run. */
+export interface TurnToolCallEvent {
+ readonly type: "tool-call";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly input: unknown;
+}
+
+/** A tool has completed execution. */
+export interface TurnToolResultEvent {
+ readonly type: "tool-result";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly content: string;
+ readonly isError: boolean;
+}
+
+/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
+export interface TurnToolOutputEvent {
+ readonly type: "tool-output";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly toolCallId: string;
+ readonly data: string;
+ readonly stream: "stdout" | "stderr";
+}
+
+/** Token usage for the current step or turn. */
+export interface TurnUsageEvent {
+ readonly type: "usage";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly usage: Usage;
+}
+
+/** An error occurred during the turn. */
+export interface TurnErrorEvent {
+ readonly type: "error";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly message: string;
+ readonly code?: string;
+}
+
+/** The turn has completed (model finished generating). */
+export interface TurnDoneEvent {
+ readonly type: "done";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly reason: string;
+}
+
+/**
+ * The turn has been sealed — all chunks persisted, history is final.
+ * This is the hook point for post-turn extensions (compaction, cache-warm).
+ */
+export interface TurnSealedEvent {
+ readonly type: "turn-sealed";
+ readonly conversationId: string;
+ readonly turnId: string;
+}
diff --git a/packages/wire/tsconfig.json b/packages/wire/tsconfig.json
new file mode 100644
index 0000000..2a3be7e
--- /dev/null
+++ b/packages/wire/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"]
+}
diff --git a/tasks.md b/tasks.md
index 6e5732e..ba08cf3 100644
--- a/tasks.md
+++ b/tasks.md
@@ -333,6 +333,51 @@ reserved in `.env`. When FE build begins: retire the AGENTS.md "Backend only for
frontend)" line, author new scoped `.dispatch/rules/frontend-*.md`, and update
ORCHESTRATOR.md §7 (repo geography) + §3 (rule scoping map).
+**STATUS — slice 1 STARTED (user front-loaded the architecture: surface system + WS FIRST,
+not chat-first).** Done: `notes/frontend-design.md` LOCKED (no-mandatory-spine model;
+chat is a decomposable feature, NOT a surface); authored `packages/ui-contract` (types-only
+surface ABI — `SurfaceSpec`/field kinds/`region`/`ActionRef`/catalog; verified green);
+scaffolded the SEPARATE repo `../dispatch-web` (Vite + Svelte 5 + vitest + biome;
+svelte-check/biome/`vite build` all green; `@dispatch/ui-contract` linked via `file:` dep);
+authored the FE harness (AGENTS/ORCHESTRATOR/GLOSSARY/.dispatch rules). Retired the AGENTS.md
+"Backend only" line; updated ORCHESTRATOR §7/§3. Vocab locked (surface/view/region/field
+kind/action+action ref/surface catalog); backend `command`→`action` unification logged as a
+future review (restructure-plan §8). NEXT (summons): backend B2 kernel wire-types split, B3
+surface-contribution mechanism, B4 `transport-ws`, B5 loaded-extensions surface; FE F1–F5.
+
+**FE SLICE 1 — DONE + verified live (2026-06-06): the surface system.** Built across both repos
+(orchestrated, mimo-v2.5-pro owner-agents): NEW backend pkgs `ui-contract` (surface ABI + WS
+protocol), `surface-registry` (typed service handle), `transport-ws` (Bun WS server :24205),
+`surface-loaded-extensions` (first surface); kernel `HostAPI.getExtensions`; NEW repo
+`../dispatch-web` (Vite+Svelte5) with `core/protocol` · `features/surface-host` · `adapters/ws` ·
+`app`. **Live WS probe: catalog → subscribe → surface rendered the 10 loaded extensions.** Backend
+460 vitest + 77 bun (typecheck+biome clean); FE 76 vitest + build (svelte-check+biome clean).
+Scar tissue captured: headless cross-repo read hang (in-repo `ui-contract.reference.md` + brief
+guards); live boot-probe tool-timeout (ORCHESTRATOR §8). Deferred: F-app CR-1 (vitest browser
+condition), DaisyUI styling, B2 kernel wire-types split (chat slice). Full plan + status:
+`notes/frontend-design.md` §10.
+
+### FE SLICE 2 — chat slice (browser chat MVP): backend prerequisites — IN PROGRESS
+The product MVP: send a message, render the streamed multi-turn response, with §6 caching/delta
+streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../dispatch-web`).
+- [x] **B2 — wire-types split** (`@dispatch/wire`, NEW types-only pkg). The pure wire ABI —
+ `AgentEvent` (+11 variants), the conversation model (`Chunk`/`ChatMessage`/`Role`/`TurnId`/
+ `StepId` + 6 chunk variants), and `Usage` — moved out of the kernel into `@dispatch/wire`
+ (zero `@dispatch/*` deps, zero runtime). `@dispatch/kernel` re-exports them via shim files →
+ its public surface is **byte-identical** (zero consumer blast radius). `transport-contract`
+ now re-exports `AgentEvent` from `@dispatch/wire` and **dropped its `@dispatch/kernel`
+ dependency** → the FE can consume the wire contract without pulling the kernel runtime (the
+ whole point of B2). Coordinated multi-file owner-agent (mimo-v2.5-pro, ORCHESTRATOR §5.5) +
+ orchestrator build wiring (new pkg scaffold, project refs, deps). typecheck + biome clean;
+ **460 vitest + 77 bun** (unchanged — pure type move). Summon: prompts/b2-wire-split.md,
+ report: reports/b2-wire-split.md.
+- [ ] **per-chunk `seq`** on `Chunk` (wire) — monotonic cursor for mid-turn incremental sync.
+- [ ] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → reconciled `Chunk[]`/
+ `ChatMessage[]` (transport-http + conversation-store).
+- [ ] **WS turn-deltas** — `transport-ws` multiplexes `sendMessage`/`onDelta(AgentEvent)`
+ alongside surface ops (one connection carries both; frontend-design §5).
+Then FE (`../dispatch-web`): `core/transcript` reducer + `conversation-cache` + `chat` feature.
+
### 3. dedup / storage growth (after frontend)
The deferred trace-body de-duplication + rotation/compression (D5 volume-control +
`prefix.fingerprint` + §6 retention strategy) — already designed in
diff --git a/tsconfig.json b/tsconfig.json
index 2935e09..58ec820 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,8 +1,13 @@
{
"files": [],
"references": [
+ { "path": "./packages/wire" },
{ "path": "./packages/kernel" },
{ "path": "./packages/transport-contract" },
+ { "path": "./packages/ui-contract" },
+ { "path": "./packages/surface-registry" },
+ { "path": "./packages/transport-ws" },
+ { "path": "./packages/surface-loaded-extensions" },
{ "path": "./packages/storage-sqlite" },
{ "path": "./packages/auth-apikey" },
{ "path": "./packages/provider-openai-compat" },
diff --git a/vitest.config.ts b/vitest.config.ts
index e04946d..33b8337 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,15 +1,17 @@
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
- // Packages whose code imports Bun-only modules (e.g. `bun:sqlite`) can't run
- // under Vite's Node transform — they test via `bun test` (see `test:bun`).
- // Everything else runs here under vitest.
- projects: [
- "packages/*",
- "!packages/storage-sqlite",
- "!packages/trace-store",
- "!packages/observability-collector",
+ // Everything runs here under vitest EXCEPT bun-only tests, which run via
+ // `test:bun` (they use real `Bun.serve` / `bun:sqlite` / `bun:test`):
+ // - `*.bun.test.ts` files (e.g. transport-ws's live WebSocket server test)
+ // - packages whose code imports Bun-only modules (`bun:sqlite`)
+ exclude: [
+ ...configDefaults.exclude,
+ "**/*.bun.test.ts",
+ "packages/storage-sqlite/**",
+ "packages/trace-store/**",
+ "packages/observability-collector/**",
],
},
});