1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
|
# Dispatch — tasks (live progress)
> **Live status + roadmap only.** Completed milestones are summarized, not
> narrated. Old blow-by-blow history is pruned — it lives in git (`git log`).
> Keep this lean and current; do not let it re-accrete a step-by-step changelog.
## Status (current)
`tsc -b` EXIT 0 · biome clean · **865 vitest + 135 bun = 1000 tests**.
Built and verified live (full-fidelity: every feature is a manifest-loaded
extension through the host):
- **kernel** — contracts (ABI), bus, `runTurn` turn loop, extension host.
- **core extensions** — storage-sqlite, auth-apikey, provider-openai-compat
(OpenCode Go), conversation-store, session-orchestrator, transport-http,
credential-store; tool extensions `read_file` (files + directory listing), `run_shell`,
`edit_file`, `write_file`.
- **observability** — structured Logger/Span ABI + journal-sink → out-of-process
collector → trace-store (`bun:sqlite`); host-bin supervises the collector;
nested turn→step→{prompt, provider.request, ttft, decode} spans; D5 verbatim
provider capture (self-redacted); `trace-replay` record/replay lib + fixtures.
- **CLI** — one-shot HTTP client (`bun packages/cli/src/main.ts`); `GET /models`,
`--cwd`, `--conversation`.
- **web frontend** — SEPARATE repo `../dispatch-web`. Slice 1 (surface system)
shipped via `ui-contract` + `surface-registry` + `transport-ws` +
`surface-loaded-extensions`. Slice 2 (browser chat) in progress there.
## How to run
```bash
KEY1=$(grep DISPATCH_API_KEY_OPENCODE1 .env | cut -d= -f2)
PORT=4567 DISPATCH_API_KEY="$KEY1" bun packages/host-bin/src/main.ts # boots app + collector
curl -s -X POST localhost:4567/chat -H 'content-type: application/json' \
-d '{"conversationId":"c1","message":"Say hello in 3 words."}' # field = conversationId
```
Process cleanup uses the `[x]` bracket trick (ORCHESTRATOR §8) — leaked
server/collector procs poison the next run's counts.
**Two stacks:** `bin/up` = dev (live-reload backend, ports 24203/24205/24204).
`../bin/up2` = a **stable, no-watch** second stack on **25203/25205/25204** with
ISOLATED data (`./.dispatch-data/up2/`, `./.dispatch/journal/up2/`) — runs ALONGSIDE
`bin/up`, edit backend code freely without restarting it; Ctrl-C stops only itself.
Enabled by a new env knob **`SURFACE_WS_PORT`** → `surfaceWsPort` config
(`host-bin/config.ts`; default 24205 when unset, so dev is unchanged).
## Foundation (done — summarized; details in git)
- **MVP + multi-turn:** curl → transport-http → session-orchestrator →
host/registry → provider → OpenCode Go → AgentEvents → NDJSON;
`conversationId` threads history.
- **Post-MVP:** auth→provider seam; `read_file` tool (live tool-dispatch loop);
`getHostAPI()` hygiene; `tabId → conversationId` rename.
- **Observability Phase A/B:** the substrate + collector/store + supervision +
replay fixtures (see bullet list above).
- **CLI MVP:** credential-store + transport-contract + cli; model catalog; cwd
threading; multi-turn.
- **FE Slice 1:** the surface system across both repos (live WS probe verified).
- **FE Slice 2 backend prereqs:** `@dispatch/wire` split; per-chunk `seq` cursor;
read endpoint `GET /conversations/:id?sinceSeq=`; WS chat-deltas (transport-ws);
turn-lifecycle events (`turn-start`/`done`/`turn-sealed`); step grouping
(`stepId` on tool chunks/events); live stream metrics (`step-complete` +
`usage`/`done` token/timing — "Pass 1"); CORS.
## Metrics — token + timing (current milestone)
- [x] **Pass 1 — live stream metrics** (done): `step-complete` event +
`usage`(stepId) + `done`(durationMs + aggregate usage).
- [x] **Observability spans** (done): turn & step span-close stamp all four
`Usage` fields (added cacheRead/cacheWrite; normalized `usage_*` → `usage.*`).
- [x] **Pass 2 — persisted replay metrics** (done, was deferred): `StepMetrics`/
`TurnMetrics` wire types; conversation-store `appendMetrics`/`loadMetrics`
(separate key space, turn-append order); session-orchestrator accumulates
per-step+turn metrics from the event stream and persists after seal;
transport-http `GET /conversations/:id/metrics` → `ConversationMetricsResponse`.
`@dispatch/wire` + `@dispatch/transport-contract` → `0.4.0`. Commit `6db12ff`.
- [x] **Live-verified end-to-end** (against flash): live `step-complete`/`done`
metrics ↔ persisted `GET /conversations/:id/metrics` byte-match (aggregate +
per-step `stepId` + ttft/decode/genTotal + durationMs); journal turn/step spans
carry dotted `usage.*` incl. `usage.cacheReadTokens` (the #2 fix).
- [x] **FE courier handoff** written: `frontend-metrics-pass2-handoff.md` (in
this repo; user couriers to `../dispatch-web`; ORCHESTRATOR §7).
## dedup / storage growth (DONE)
Design `notes/observability-design.md` §12. User-gated calls: extend existing
pipeline (no new ext); scope = **de-dup + retention/rotation** (D9 roll-ups
deferred); dedup = **content-addressed bodies** (body-hash, NOT fingerprint-gated).
- [x] **Wave 1 — `trace-store`**: content-addressed `bodies` table (SHA-256),
at-rest gzip (>1 KiB), `prune(policy)` (age + drop-oldest byte-cap + orphan GC) /
`RetentionPolicy` / `PruneSummary` / `DEFAULT_RETENTION` (7d/256MiB); reads
transparent.
- [x] **Wave 2 — `observability-collector`**: pure `shouldPrune` cadence helper;
`main.ts` calls `store.prune(DEFAULT_RETENTION)` on a coarse cadence
(`--prune-interval-ms`, default 60s; host-bin-overridable), log-and-continue on
error.
- [x] Glossary: added content-addressed body, trace retention, prefix fingerprint,
warm vs real.
- [x] **Migration bug** (found by live boot, fixed): Wave 1 created the
`idx_records_bodyHash` index BEFORE running `migrateOldBodies`, so opening a
pre-existing OLD-schema `traces.db` crashed the collector
(`no such column: bodyHash`, crash-looped). Fix = reorder migration before the
index + 3 regression tests that seed a real old-schema DB. bun 106→109.
- Tests: bun 89→109. typecheck/biome clean. **Live-verified** against a real
old-schema `traces.db`: 0 crashes, collector stays up, schema migrates
(bodyHash + content-addressed bodies), real-data dedup (318 body refs → 270
stored bodies), prune cadence fires cleanly (14× `prune completed`). Optional
follow-up: host-bin env-override for the retention policy.
## Standard tools — fs + shell (DONE)
User-gated calls: **one tool per extension** (matches `tool-read-file` precedent); tools are
**standard** tier (a turn completes with `tools:[]`, §2.6/§2.8). **Zero ABI change** — the
`ToolContract`/`ToolExecuteContext` already carry `signal`/`onOutput`/`cwd`/`log`.
- **Wave 1 (parallel, disjoint pkgs, kernel-only dep) — all green:**
- [x] `tool-read-file` — EXTENDED `read_file` to list directory contents (sorted, `/`-suffixed
subdirs; files unchanged). 41 tests.
- [x] `tool-shell` (new) — `run_shell`: foreground, streamed via `ctx.onOutput`, `ctx.signal`
cancel, `ctx.cwd`, timeout + output cap, `concurrencySafe:false`; injected `spawn`. 31 tests.
- [x] `tool-edit-file` (new) — `edit_file`: `oldString`/`newString`/`replaceAll`; errors on
absent/non-unique/identical; workdir-contained; `concurrencySafe:false`. 38 tests.
- [x] `tool-write-file` (new) — `write_file`: explicit `overwrite` flag (absent+unset→create;
exists+unset→error; exists+true→overwrite; absent+true→error); no parent auto-create. 33 tests.
- **Wave 2 (done):** orchestrator added 3 root tsconfig refs + `bun install`; host-bin owner
registered the 3 new extensions in `CORE_EXTENSIONS` (same pattern as `read_file`).
- **Live-verified:** clean boot (`Dispatch booted`, collector up, no activation/capability-gate
error — the new `shell` capability is accepted); full-graph `tsc -b` EXIT 0, biome clean.
- **Recovery notes (scar tissue):** `tool-write-file` first returned plan-only (§5a) → re-summoned
with "IMPLEMENT NOW". `tool-edit-file` hung vitest at collection — `computeReplacement` infinite-
looped on empty `oldString` (`"".indexOf("") === 0`, index never advances) invoked at a test's
`describe` scope; fixed with an early empty-string guard + validation. One agent deleted
`ORCHESTRATOR.md` out-of-lane → caught by post-wave `git status`, restored from git.
- Deferred (not selected): `glob`, `grep`/`search_code`, background shells.
## Skill system + load_skill tool (DONE)
User-gated calls: skills list lives in the **`load_skill` tool definition** (NOT the system prompt),
refreshed **per new turn** (cache-stable across steps), **live file read** on execute. One `skills`
standard extension (loader + filter + tool). Skill = md in `.skills/`; discovered from `~/.skills` +
`<cwd>/.skills` (cwd shadows home); name = filename w/o `.md`. Format: line1 = summary,
line2 = `---`, body = line3+; on load the first two lines are stripped; malformed (no `---`) =
no summary but still loadable. Glossary: added `skill`, `skill summary`, `tools filter`.
- **Mechanism — the per-turn `tools` filter chain** (first concrete use of the §3.2 context-assembly
chain; reusable for persona/agents later):
- [x] **kernel** — exposed `HostAPI.applyFilters` (delegates to the bus's existing `applyFilters`).
- [x] **session-orchestrator** — defines+exports `toolsFilter`/`ToolAssembly`; applies it ONCE per
turn (injected `applyToolsFilter` dep) before `runTurn`, threading `cwd`+`conversationId`.
- [x] **skills** (new ext, `dependsOn session-orchestrator`) — pure parse/merge/render +
`load_skill` tool (live read, strips first two lines, path-contained) + a `toolsFilter` filter
that rewrites `load_skill`'s description + `name` enum with the per-cwd catalog. 42 tests.
- [x] **host-bin** — registered `skills` in `CORE_EXTENSIONS`.
- [x] **Fan-out (§5.3):** `applyFilters` was a required `HostAPI` addition → broke one consumer
(transport-http `server.bun.test.ts` inline HostAPI stub) → fixed by its owner.
- **Live-verified:** clean boot (`skills` activates, filter registered, no crash); full-graph
`tsc -b` EXIT 0, biome clean. (End-to-end load_skill via a real LLM turn not yet exercised —
unit/integration tests cover the filter rewrite + live read.)
## Cache warming (core DONE; control surface PARTIAL)
User-gated calls: target the external **Claude** provider (`../claude` provider-anthropic, loaded via
`DISPATCH_EXTERNAL_EXTENSIONS`); warm-assembly lives in **session-orchestrator** (`warm()` reuses the
real turn's assembly → byte-identical prefix, provider-agnostic); **surface system** for controls;
**per-conversation** controls; interval default 4 min, free value. Old-code invariants honored
(primary-model/full-prefix via reuse; refuse mid-turn; never persist/emit; in-flight invalidation;
arm-on-settle/cancel-on-start; `pct = round(clamp(cacheRead/input,0,1)*100)`).
- **Mechanism (2nd use of bus hooks; first event-hook emit):**
- [x] **kernel** — exposed `HostAPI.emit` (delegates to bus.emit), counterpart of `on`.
- [x] **session-orchestrator** — `turnStarted`/`turnSettled` event hooks (carry conversationId/cwd/
modelName) emitted per turn; `warm()` service (`cacheWarmHandle`) reusing assembly, refusing
mid-turn, never persisting/emitting; returns Usage.
- [x] **cache-warming** (new ext) — per-conversation timers (arm/cancel/in-flight token),
calls `warm()`, computes `lastPct`, persists `{enabled,intervalMs}` (default on/240s) in
host.storage; registers a controls Surface. 19 tests.
- [x] **host-bin** — registered cache-warming; **transport-http** HostAPI stub fixed for `emit`.
- **Manual trigger endpoint:** `POST /chat/warm {conversationId, model?, cwd?}` → `WarmResponse`
`{inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,cachePct}` (409 if generating). Powers a
FE "warm now" button + fast tests. Types in `@dispatch/transport-contract`; route in transport-http.
- **LIVE-VERIFIED against Claude haiku:** automatic timer warm → journal `warm complete pct:100`;
manual `POST /chat/warm` → `cacheReadTokens:6799, cachePct:100` (100% hit), HTTP 200. The external
`../claude` provider-anthropic is loaded via `bin/up` (`DISPATCH_EXTERNAL_EXTENSIONS`).
- **Cache-metric fix + retention metric:** `provider-anthropic` (in `../claude`, commit `0e9d118`)
now reports `Usage.inputTokens` as the TOTAL prompt (was the uncached remainder → the cache rate
inflated/clamped to 100% on Claude). So `cacheRead/inputTokens` is now the true rate (live: a turn
adding new content reads 61%, not 100%). Added **`expectedCacheRate`** = `cacheRead/(cacheRead+
cacheWrite)` (retention/health, ~100% when warm, 0% when the cache expired) to `WarmResponse` +
`POST /chat/warm` + the cache-warming surface (a "cache retention" stat). Live-verified: warm
within TTL → 100%; warm after >5 min idle → 0% (cache expired). FE handoff updated with both
metrics + the cross-turn real-turn `expectedCache = cacheRead_N/(cacheRead_{N-1}+cacheWrite_{N-1})`.
- **Surface framework extended (DONE):** added `NumberField` to `ui-contract` + per-conversation
surface scoping (optional `conversationId` on subscribe/unsubscribe/invoke + surface/update; new
`SurfaceContext` on `SurfaceProvider.getSpec/invoke`; transport-ws keys subscriptions by
`(surfaceId, conversationId)` and tags updates). cache-warming now serves a PER-CONVERSATION
surface: `Toggle`(enabled) · `Number`(interval, seconds, `cache-warming/set-interval`) ·
`Stat`(last cache %). All backward-compatible (global surfaces like `surface-loaded-extensions`
unchanged). **FE courier:** `frontend-cache-warming-handoff.md` (this repo) — the web must render
the `number` field kind + send/handle `conversationId` on the surface WS protocol.
## Cache warming — FE CR-3 (DONE)
FE asked (dispatch-web `backend-handoff-cache-warming-timer.md`): expose next/last-warm timestamps +
make a manual warm reset the timer/refresh the surface. Done via an **inversion** (commit `bfbad3a`):
session-orchestrator `warm()` (the single chokepoint for manual `/chat/warm` AND the auto timer) emits
a `warmCompleted` bus event; cache-warming subscribes and does all post-warm handling — so manual
warms re-arm the timer + push a surface update with **no transport-http change** (core can't depend on
the standard cache-warming ext). Added `nextWarmAt`/`lastWarmAt` state + a `custom`
`rendererId:"cache-warming-timer"` surface field (no ui-contract bump). Caught + fixed a wiring bug
(`createWarmService` missed the `emit` dep → `deps.emit?.` silently no-oped; made it required).
Live-verified vs claude haiku (manual warm logs `warm complete` ~2s after the turn, not the 4-min
timer). FE handoff updated. (FE CR-1 table + CR-2 catalog `scope` flag still open, not requested.)
## LSP integration + per-conversation CWD (DONE)
Design: `notes/lsp-design.md`. FE courier: `frontend-lsp-cwd-handoff.md`. Decisions
(locked): **single `lsp` extension**; **hand-rolled pure JSON-RPC codec** (zero dep,
injected-stream tested); **diagnostics-on-write deferred** (on-demand `lsp` tool
only); **cwd persisted in `conversation-store`**; config = **built-in TypeScript +
`<cwd>/.dispatch/lsp.json` + `<cwd>/opencode.json` `lsp` fallback** (Roblox works
with its existing config). Glossary: added LSP, language server, diagnostics,
workspace root, working directory.
- **The bug we fixed** (opencode root cause, confirmed): opencode's
`client/registerCapability` ignores all but `textDocument/diagnostic`, so
`workspace/didChangeWatchedFiles` registrations are dropped + no real fs watcher
→ stale `sourcemap.json` → "Unknown require" mid-session. Fix = honor the
registration + real fs watcher + forward `didChangeWatchedFiles` + auto-spawn
`rojo sourcemap --watch` sidecar when `luau-lsp.sourcemap.autogenerate`. Covered
by a regression test in `packages/lsp/src/client.test.ts`.
- **`lsp` extension** (new, bundled core): hand-rolled LSP client (framing + rpc +
watched-files + diagnostics + config + root + tool + manager), zero external deps.
Lazy-spawn one server per `(serverID, root)`; config resolved **per cwd**;
`lspServiceHandle.status(cwd)` lazy-connects + reports state; `deactivate` kills
all child procs (host-bin shutdown now calls `host.deactivate()`).
- **CWD:** `conversation-store.getCwd/setCwd`; `session-orchestrator` defaults a
turn's cwd from the store; endpoints `GET`/`PUT /conversations/:id/cwd` +
`GET /conversations/:id/lsp` in transport-http; wire types in
`@dispatch/transport-contract` (→ `0.5.0`).
- **LIVE-VERIFIED:** this repo (`typescript`) → `connected`; `/home/tradam/projects/
roblox` (`luau-lsp`) → `connected` (via the project's own `opencode.json` + rojo
sidecar); cwd PUT/GET round-trip 200. Op note: LSP binaries must be on the server
process PATH (`~/.local/bin` daemon-PATH caveat for `typescript-language-server`).
- **Recovery (scar tissue):** the `lsp` agent stalled on the final stretch (1 hung
test + ~40 biome `!`/dot-key findings) → at the user's request the orchestrator
finished it directly; also fixed a real design bug the agent missed: the manager
read config statically instead of per-cwd (would have broken Roblox).
## Open items
- **`prefix.fingerprint` / `warm|real` cache-bust attributes (deferred):** decoupled
from dedup by the content-addressed decision; also gated on cache-warming being
built (not yet) so `warm|real` can't be honestly stamped. Later cache-bust-debug
milestone (`notes/observability-design.md` §3.1, §12).
- **D9 analytics roll-ups (deferred):** rollup table shape + `GROUP BY` indexes +
retention asymmetry + periodic rollup job (`notes/observability-design.md` §2 D9,
§12). The scheduler mechanism (`host.scheduler.register`) already exists.
- **D8 `prompt.assembly` segments:** deferred-by-design (await the context-filter
chain).
## Roadmap
1. **CLI** — done.
2. **Web frontend** (in progress, SEPARATE repo `../dispatch-web`; Svelte +
DaisyUI, same methodology). Slice 2 = browser chat MVP consuming the
wire/transport-contract + metrics. Cross-repo contract changes are couriered
via the user (ORCHESTRATOR §7); `lsp references` does not span repos.
3. **dedup / storage growth** — after the frontend (see Open items).
|