summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-contract/src/index.ts
AgeCommit message (Collapse)Author
8 daysMerge branch 'feature/heartbeat-inactive-only' into predevAdam Malczewski
8 daysMerge branch 'feature/summon-title' into predevAdam Malczewski
10 daysfeat(heartbeat): add inactiveOnly mode (skip fire while workspace has active ↵Adam Malczewski
agents) A new per-workspace heartbeat setting `inactiveOnly` (default true): when on, the heartbeat SKIPS a scheduled fire whenever the configured workspace has any active agents — a conversation whose persisted status is "active" (driving a turn) or "queued" (waiting on the message queue). The fire is silently skipped (no run recorded); the scheduler re-arms and retries at the next interval. Set false to fire unconditionally (the prior behavior). The check is wired against conversationStore.listConversations({ workspaceId, status: ["active","queued"] }) at fire time — the orchestrator sets "active" on turn start and "idle" on settle, so it is the live busy/idle signal. The spawned heartbeat conversation lives in the dedicated heartbeat workspace, so a heartbeat run never counts as an active agent of the configured workspace (no self-block). - transport-contract: add inactiveOnly to HeartbeatConfig + UpdateHeartbeatRequest - heartbeat config-store: default true, apply/persist, legacy-parse default true - heartbeat service: injectable hasActiveAgents dep + skip logic in fire() - heartbeat extension: wire hasActiveAgents against the conversation store - transport-http: validate inactiveOnly (boolean) on PUT /workspaces/:id/heartbeat - tests: config-store (+4), heartbeat service (+7), transport-http (+3) - notes/heartbeat-setting-handoff.md: API contract for the frontend Verification: typecheck EXIT 0; tests 2013 passed | 6 skipped; biome EXIT 0.
10 daysfix(summon-title): defer title set until after workspace initializationAdam Malczewski
Setting the title in the HTTP /chat route BEFORE the turn started pre-created the conversation meta, which made the orchestrator's getConversationMeta === null newness check falsely report an EXISTING conversation. As a result ensureWorkspace / setWorkspaceId / the first-turn systemPromptService.construct were ALL skipped for summoned conversations with a title — leaving them with no assigned workspace and breaking downstream cwd resolution + system-prompt init. Fix: the title is no longer set in the route. It is threaded through StartTurnInput -> handleMessage -> startTurn -> runTurnDetached and persisted via setConversationTitle INSIDE workspaceSetupPromise, AFTER the newness check + workspace assignment (so init still fires for new conversations) and BEFORE the first message append (so the append's auto-derived title does not overwrite it). The title set is best-effort: a failure is logged but does not break the turn (the workspace setup already succeeded). The /chat route now forwards title into the orchestrator input instead of calling setConversationTitle itself. Tests: - app.test.ts: route tests rewritten to assert the route forwards title to the orchestrator and does NOT call setConversationTitle itself. - orchestrator.test.ts: 5 new regression tests, including the critical 'titled new conversation still gets workspace assigned + system-prompt constructed + title set', an ordering test (title set after getMeta/ensureWorkspace/ setWorkspaceId), and a resilience test (turn completes if setConversationTitle throws). Verification: typecheck EXIT 0; test 2026 passed | 6 skipped | 0 failed; check EXIT 0 (12 pre-existing warnings in untouched files).
10 daysMerge branch 'feature/summon-title' into predevAdam Malczewski
10 daysfeat(message-queue): cancel a queued steering message by idAdam Malczewski
Add the ability to cancel/close a single queued message so it never runs: while a turn is GENERATING and a user message sits in the steering queue (waiting for delivery at the next tool-result boundary or carry into a new turn), a client can remove one message by id. The cancelled message is never delivered as steering and never carried into a new turn. Complements the existing chat.queue enqueue (enqueue adds; cancel removes one). Layers (all additive; nothing existing breaks): - message-queue pure core: `cancel(state, conversationId, messageId)` — splices a single message out by id, returns the post-cancel snapshot. Idempotent (no-op if not found). Drops the key when the queue empties (mirrors drain). - message-queue service: `MessageQueueService.cancel()` — wraps the pure op, pushes a surface update ONLY on a real change (queue shrank); a missing-id cancel is a no-op with no surface push (mirrors drain's no-notify-on-empty). - session-orchestrator: `cancelQueuedMessage({ conversationId, messageId })` -> `{ cancelled, queue }`. The single entry transports call; resolves the queue lazily (same as enqueue). Degrades to `{ cancelled: false, queue: [] }` when the message-queue extension isn't loaded. `cancelled` is derived from the queue length delta (true iff a message was removed). API contract (documented for the frontend agent; see frontend-cancel-queued-message-handoff.md): HTTP: DELETE /conversations/:id/queue/:messageId -> 200 QueueCancelResponse { conversationId, cancelled, queue } (cancelled:false is a 200 idempotent no-op, not an error) WS: chat.queue.cancel { type:"chat.queue.cancel", conversationId, messageId } (additive to WsClientMessage). Fire-and-forget like chat.queue: success is confirmed by the message-queue SURFACE updating (the cancelled message leaves payload.messages). A missing-id cancel is a silent no-op (no surface update, no error). Malformed (empty conversationId/ messageId) -> chat.error. No new AgentEvent: a cancelled message never appears in the transcript (it never runs). The existing message-queue surface already reflects the post-cancel snapshot. Race-safe by construction: if the kernel drains the queue (steering) or carries it into a new turn before the cancel runs, the message is already gone -> cancel returns cancelled:false (a no-op). Version bump: @dispatch/transport-contract 0.23.0 -> 0.24.0 (additive: QueueCancelResponse + ChatQueueCancelMessage added to WsClientMessage). @dispatch/wire unchanged (QueuedMessage.id is the cancel target). No CLI command added: the CLI has no queue-listing affordance to discover a messageId, so a CLI cancel would have no input source. The HTTP DELETE is available for any non-WS client that knows the id (e.g. from a prior enqueue response's queue[]). Verification: tsc -b EXIT 0; vitest 2024 passed / 6 skipped (25 new tests); biome EXIT 0 (0 errors).
10 daysfeat(cli): add --title flag to set tab title when summoningAdam Malczewski
Add a --title <title> flag to the dispatch CLI so a summoned agent's conversation/tab gets a human-readable title at creation time instead of the auto-derived default (Untitled until the first message append). The title is carried as an additive optional ChatRequest.title field (the HTTP client-server contract), parsed + trimmed server-side in parseChatBody, and persisted via the conversation store's setConversationTitle in the POST /chat route BEFORE the turn starts — so the title is set atomically with creation and before --open signals the frontend to open the tab. No second HTTP round-trip is needed. A whitespace-only title is treated as absent (auto-derive); a non-string title yields HTTP 400. A title-set failure is logged but never blocks the turn (the title is a nicety, the answer is not). The change is purely additive — no existing behavior or assertion changes. Verification: typecheck EXIT 0; test 2021 passed | 6 skipped | 0 failed; check EXIT 0 (12 pre-existing warnings in untouched files).
10 daysfeat(concurrency-fixes): usage-gate + adaptive headroom + configurable cooldownAdam Malczewski
11 daysMerge branch 'feature/vision-handoff' into devAdam Malczewski
# Conflicts: # packages/session-orchestrator/src/extension.ts # packages/session-orchestrator/src/orchestrator.ts
11 daysfeat(vision): image compaction for vision-capable models + global vision ↵Adam Malczewski
settings
12 daysfeat(vision-handoff): implement vision for capable models and universal ↵Adam Malczewski
vision handoff
12 daysfeat(provider-concurrency): implement per-provider in-memory concurrency ↵Adam Malczewski
limits with oldest-agent-first scheduling
12 daysstyle: reformat heartbeat merge to 2-space indentationAdam Malczewski
12 daysMerge branch 'dev' into feature/heartbeatAdam Malczewski
# Conflicts: # packages/host-bin/package.json # packages/host-bin/src/main.ts # packages/session-orchestrator/src/orchestrator.ts # packages/system-prompt/src/service.test.ts # packages/system-prompt/src/service.ts # packages/system-prompt/src/types.ts # packages/transport-contract/package.json # packages/transport-http/package.json # packages/transport-http/src/app.test.ts # packages/transport-http/src/app.ts # packages/transport-http/src/extension.ts # packages/transport-http/tsconfig.json # tsconfig.json
12 daysstyle: switch from tabs to 2-space indentationAdam Malczewski
13 daysfeat(heartbeat): workspace heartbeat loop with configurable AI monitoringAdam Malczewski
13 daysfeat(ssh): wave 3 — session-orchestrator computerId threading + ↵Adam Malczewski
transport-contract API types Wave 3 of transparent SSH support (2 parallel owner-agents on disjoint packages). - session-orchestrator: thread computerId end-to-end through the turn, mirroring cwd exactly — StartTurnInput/EnqueueInput/handleMessage/TurnLifecyclePayload gain computerId; runTurnDetached resolves effectiveComputerId via conversationStore.getEffectiveComputer(convId, override), persists the override, threads into RunTurnInput + ToolAssembly. Register a remote-degradation tools-filter (filterRemoteIncompatibleTools) that, when assembly.computerId is set (REMOTE), drops the 'lsp' tool + any '__'-namespaced MCP tool (local processes that can't see remote files); LOCAL (computerId undefined) is a passthrough — byte-identical to today. +21 tests. - transport-contract: + computerId on ChatRequest (flows to ChatSendMessage) + computer endpoint API types (ComputerListResponse, ComputerResponse, ComputerStatusResponse, SetConversationComputerRequest, ConversationComputerResponse, SetWorkspaceDefaultComputerRequest, TestComputerResponse) — mirrors the cwd/workspace endpoint types. - CR-1 (non-blocking, folded into wave 4): MCP filter doesn't preserve computerId on the returned ToolAssembly. - cache-warming computerId threading intentionally DEFERRED (user request) — noted as a known performance-only limitation in tasks.md. Verified: tsc -b EXIT 0, biome clean, 1620 vitest pass (was 1599, +21). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
2026-06-24feat(transport-contract): add McpServerInfo + McpStatusResponse (0.22.0)Adam Malczewski
Additive types for GET /conversations/:id/mcp status endpoint, mirroring the existing LSP status types. McpServerState, McpServerInfo, McpStatusResponse. +2 type-test assertions. Version bump 0.21.0 → 0.22.0. Handoff written: frontend-mcp-status-handoff.md (backend route + FE consumption).
2026-06-24fix(lsp): broken-server recovery + config source attributionAdam Malczewski
Two issues found by decompiling the running dispatch-server binary (handoff from a ruby-lsp setup in raylib-jamstack): Issue 2 (blocker): a failed LSP server was "broken" FOREVER — the manager's broken set was cleared only in shutdownAll(), so a server that failed (bad env, missing binary, or a since-fixed config) stayed state:"error" for the whole process. For an agent running *inside* dispatch the only recovery (server restart) kills its own session. Now a broken server self-heals when its resolved config changes since it was marked broken (discrete event → no retry storm), with a bounded backoff for transient failures. Issue 1: .dispatch/lsp.json silently shadowed opencode.json's lsp key with no warning and no source attribution. Now: shadow warning via host.logger when both declare lsp; configSource populated on status (.dispatch/lsp.json / opencode.json / built-in); spawn-failure error strings name the config source. Contract: additive configSource?: string on LspServerInfo (@dispatch/transport-contract 0.20.0→0.21.0). transport-http passes it through to the wire (was a field-by-field map that dropped it — CR resolved by the transport-http owner). tsc -b EXIT 0, biome clean, 1443 vitest pass.
2026-06-24feat: persistent per-conversation model selectionAdam Malczewski
A chat's selected provider + model is now persisted per conversation (like cwd and reasoningEffort). Opening a conversation in a new browser recalls the originally selected model instead of defaulting. - transport-contract 0.19.0→0.20.0: ModelResponse + SetModelRequest types for GET/PUT /conversations/:id/model. - conversation-store: getModel/setModel (model:<id> key, mirrors getReasoningEffort/setReasoningEffort); forkHistory copies model; empty string clears. - session-orchestrator: resolve model from persisted store when no per-turn override; persist the resolved model so it sticks; warm path parity. - transport-http: GET/PUT /conversations/:id/model endpoints with validation. 1433 vitest pass; tsc + biome clean.
2026-06-24workspace: conversation.open/statusChanged carry workspaceId (1405 vitest)Adam Malczewski
- @dispatch/transport-contract 0.18.0 -> 0.19.0: add workspaceId: string to ConversationOpenMessage and ConversationStatusChangedMessage - session-orchestrator: include persisted workspaceId in conversationOpened/ conversationStatusChanged payloads - transport-ws: forward workspaceId in WS broadcasts - transport-http: POST /conversations/:id/open resolves workspaceId before emit - FE handoff to 29ae: frontend-workspace-open-handoff.md
2026-06-23feat(system-prompt): template-based system prompt builder extensionAdam Malczewski
New @dispatch/system-prompt extension (standard tier): - Pure parser: [type:name] variables, [if]/[else]/[endif] conditionals, negated [if !...], nested blocks, unmatched-tag pass-through. - Variable resolver (injected adapters): system:time/date/os/hostname, prompt:cwd/model/conversation_id, git:branch/status, file:<path> (dynamic). - Service handle: construct (resolve+persist) + get (cached, cache-safe). - Default template: persona + AGENTS.md if exists + cwd. - 52 tests (parser 29, resolver 12, catalog 3, service 8). transport-contract 0.17.0→0.18.0: SystemPromptTemplateResponse, SetSystemPromptTemplateRequest, SystemPromptVariable, SystemPromptVariablesResponse. Design: notes/system-prompt-design.md (caching constraint, compaction integration, wave plan). 1384 vitest pass.
2026-06-23fix(lsp): gate LSP endpoint on persisted cwd; accept workspaceId on PUT cwdAdam Malczewski
GET /conversations/:id/lsp was calling getEffectiveCwd directly, which falls through to serverDefaultCwd (process.cwd()) when no conversation cwd is set. Now gates on getCwd first: returns {cwd:null, servers:[]} when no cwd persisted; only resolves via getEffectiveCwd + calls lspService.status when a persisted cwd exists. PUT /conversations/:id/cwd now accepts optional workspaceId — validates with isValidWorkspaceSlug, then ensureWorkspace → setWorkspaceId → setCwd (assigns the workspace before persisting cwd, so getEffectiveCwd resolves relative cwds against the workspace defaultCwd, not the server default). transport-contract 0.16.0→0.17.0 (additive SetCwdRequest.workspaceId; LspStatusResponse.cwd comment updated). 1332 vitest pass.
2026-06-23feat: workspaces contract + conversation-store implementation (Wave 0+1)Adam Malczewski
Wire 0.12.0: Workspace, WorkspaceEntry, ConversationMeta.workspaceId Transport-contract 0.16.0: workspaceId on ChatRequest/QueueRequest/ChatQueueMessage; workspace endpoint types (EnsureWorkspaceRequest, WorkspaceResponse, etc.) Kernel: re-export Workspace/WorkspaceEntry from contracts Conversation-store: workspace persistence + service methods (getWorkspace, ensureWorkspace, setWorkspaceTitle, setWorkspaceDefaultCwd, deleteWorkspace, listWorkspaces, getWorkspaceId, setWorkspaceId, getEffectiveCwd, isValidWorkspaceSlug); listConversations filter by workspaceId; forkHistory/replaceHistory preserve workspaceId. 111 tests pass. FE handoff: frontend-workspaces-handoff.md (courier doc) 18 typecheck errors in session-orchestrator/transport-http/cli test fakes (expected fan-out — fixed in Wave 2+3).
2026-06-22feat: context window from model endpoints + percentage-based auto-compactAdam Malczewski
ModelInfo (kernel contract): - Add contextWindow?: number field OpenAI-stream listModels: - Parse contextWindow from common field names (context_length, context_window, max_context_length, max_tokens) Transport-contract: - ModelsResponse: add optional modelInfo map (model name → { contextWindow? }) - Add ModelMetadata type - Rename CompactThresholdResponse → CompactPercentResponse - Rename SetCompactThresholdRequest → SetCompactPercentRequest Credential store: - Add getModelInfo(modelName) method — resolves full ModelInfo (including contextWindow) for a <credential>/<model> string Transport-http: - GET /models now includes modelInfo with contextWindow per model - Rename compact-threshold endpoints → compact-percent Session-orchestrator: - Auto-compact now uses contextSize (not overcounted usage.inputTokens) compared against contextWindow * (percent / 100) - Default percent: 85 (was flat 350000) - resolveModelInfo dep added to look up contextWindow - Passes modelName from the settled turn to the compaction service Conversation store: - Rename getCompactThreshold/setCompactThreshold → getCompactPercent/setCompactPercent - compactThresholdKey → compact-percent key
2026-06-22fix: compaction keeps original ID, forks old history to archive, chains via ↵Adam Malczewski
compactedFrom Reworked compaction to match the confirmed design: - The compacted conversation KEEPS its original ID (messaging between agents is unaffected — the ID never changes) - The old full history is forked to a new archive conversation (new UUID) - The archive inherits the source's compactedFrom, creating a chain: A → Y → X (walk compactedFrom backward) - A's history is replaced with [summary + recent N] - A.compactedFrom = archive ID forkHistory: inherit compactedFrom from source (not set to sourceId), so archives chain backward to previous archives. FE: no tab switching needed — the ID doesn't change. Just reload history.
2026-06-22feat: non-destructive compaction — fork history to archive before replacingAdam Malczewski
Compaction now preserves the full pre-compaction history: 1. Forks the conversation to a new archive ID (complete copy: chunks, metadata, cwd, reasoning-effort). Archive gets status=closed, title='Archive: <original>', compactedFrom=<originalId>. 2. Replaces the original conversation's history with [system: summary] + recent N messages (same as before). 3. Sets compactedFrom=<archiveId> on the original conversation's metadata. The original history is never destroyed. The archive is accessible via GET /conversations/:id using the archive ID. Wire/contract changes: - ConversationMeta: add compactedFrom?: string - CompactionResult: add archiveId: string - ConversationCompactedMessage: add archiveId - CompactResponse: add archiveId Conversation store: - forkHistory(sourceId, targetId): copies all chunks + metadata to a new conversation ID - setCompactedFrom(conversationId, archiveId): marks the conversation
2026-06-22feat: conversation compacting (manual + automatic)Adam Malczewski
Implement roadmap item 10: conversation compaction to reclaim context window without losing the thread. Wire (0.11.0): - Add CompactionResult type - Add ConversationCompactedMessage WS event Transport-contract (0.15.0): - Add CompactResponse, CompactThresholdResponse, SetCompactThresholdRequest - Add ConversationCompactedMessage to WsServerMessage union - Re-export CompactionResult Conversation-store: - replaceHistory: delete all chunks, reset seq, append new messages - getCompactThreshold / setCompactThreshold (per-conversation setting) - compactThresholdKey added to keys.ts Session-orchestrator: - CompactionService interface + compactionHandle - conversationCompacted hook descriptor - createCompactionService: load history, split old/recent, call provider to summarize, replaceHistory with [system: summary] + recent N - Auto-trigger: resolveCompaction lazy dep, fires after turn settles (checks threshold, non-blocking) - Hook declared in manifest contributes.hooks + services Transport-http: - POST /conversations/:id/compact (manual trigger) - GET /conversations/:id/compact-threshold (read setting) - PUT /conversations/:id/compact-threshold (set setting) Transport-ws: - Subscribe to conversationCompacted hook - Broadcast conversation.compacted WS message CLI: - dispatch compact <conversationId> command FE handoff: frontend-compaction-handoff.md
2026-06-22feat: conversation lifecycle status (active/idle/closed) for tab persistenceAdam Malczewski
Implement roadmap item 9: tab persistence across devices. Wire (0.10.0): - Add ConversationStatus type (active | idle | closed) - Add status field to ConversationMeta Transport-contract (0.14.0): - Add conversation.statusChanged WS message to WsServerMessage union - Re-export ConversationStatus Conversation-store: - Track status in ConversationMetaRow (default: idle) - getConversationStatus / setConversationStatus methods - listConversations accepts { status: ConversationStatus[] } filter - Old meta rows without status default to idle on read Session-orchestrator: - conversationStatusChanged hook descriptor - Emit on transitions: idle→active (turn start), active→idle (turn settle), →closed (closeConversation) - Persist status to store as fire-and-forget side effect - Declare hook in manifest contributes.hooks Transport-ws: - Subscribe to conversationStatusChanged hook - Broadcast conversation.statusChanged WS message to all clients Transport-http: - GET /conversations?status=active,idle filter (parseStatusFilter pure helper) - POST /conversations/:id/close now sets status to closed CLI: - dispatch list defaults to active,idle (excludes closed) - --status <state> flag to filter by single status - --all flag to include closed FE handoff: frontend-conversation-lifecycle-handoff.md
2026-06-21feat(cli): Wave 0 — contracts for conversation list, last message, open tabAdam Malczewski
Additive contract changes for the CLI milestone (roadmap items 2 + 4): @dispatch/wire 0.8.0 → 0.9.0: - ConversationMeta { id, createdAt, lastActivityAt, title } @dispatch/transport-contract 0.12.0 → 0.13.0: - ConversationListResponse, LastMessageResponse, OpenConversationResponse - SetTitleRequest, TitleResponse - WS conversation.open broadcast (additive to WsServerMessage) ConversationStore interface: - listConversations(), getConversationMeta(), setConversationTitle() - Stub implementations in real store + 11 test fakes (Wave 1 fills in) Transport-http manifest: new routes declared (GET /conversations, GET /conversations/:id/last, POST /conversations/:id/open, PUT /conversations/:id/title)
2026-06-21feat(message-queue): per-conversation queue + steering injectionAdam Malczewski
A per-conversation message queue (new message-queue extension) holds user messages enqueued while a turn generates; delivered mid-turn as steering at the tool-result boundary (or carried to a new turn if no tool call fires). - kernel: RunTurnInput.drainSteering callback (generic; kernel stays pure) - wire 0.7.0->0.8.0: QueuedMessage, QueuePayload, TurnSteeringEvent (additive) - transport-contract 0.11.0->0.12.0: POST /conversations/:id/queue + chat.queue WS op - message-queue ext: queue state + per-conversation custom surface (rendererId message-queue) - session-orchestrator: enqueue facade + drainSteering wiring + post-seal carry - transport-http/ws: queue endpoint + chat.queue op (fixes WsClientMessage exhaustive switch) - host-bin: register message-queue 1043 vitest + 199 transport bun pass; tsc/biome clean; boot smoke clean. FE courier: frontend-message-queue-handoff.md.
2026-06-12feat(contracts): reasoning effort — ReasoningEffort ladder (low..max), ↵Adam Malczewski
ProviderStreamOptions/ChatRequest fields, per-conversation GET/PUT types wire 0.6.1->0.7.0, transport-contract 0.10.0->0.11.0. Additive only; typecheck+biome clean.
2026-06-12feat(history): CR-5 windowed reads — ?limit= / ?beforeSeq= on GET ↵Adam Malczewski
/conversations/:id Selection sinceSeq < seq < beforeSeq; newest-limit window, ascending; positive- integer validation (400, store never sees an invalid window); 1-based gap-free seq codified as the contractual has-older mechanism (no earliestSeq field). transport-contract 0.9.0->0.10.0, wire 0.6.0->0.6.1 (doc-only). conversation-store +8 tests, transport-http +20; 935 vitest + 112 bun green. Live-verified: 6/6 probe checks OK. FE courier: frontend-history-windowing-handoff.md
2026-06-12feat(cache-warming): lifecycle CR-4 — default-off, fresh nextWarmAt, ↵Adam Malczewski
conversation close (+CR-1 table, CR-2 scope) CR-4a: warming defaults OFF (opt-in per conversation); re-enabling restores the persisted interval. CR-4b: re-arm BEFORE surface notify so post-warm updates carry the FUTURE nextWarmAt; turnSettled/turnStarted now also push (fresh schedule after seal, null while generating). CR-4c: POST /conversations/:id/close — per-turn AbortController wired to the kernel runTurn signal (partial persist + normal seal, done.reason "aborted"), new conversationClosed hook, cache-warming disables sync + persists OFF. Disconnect/chat.unsubscribe semantics unchanged. CR-4d: no change needed — initial surface echo already at HEAD (stale up2 boot on the FE probe). CR-1: loaded-extensions emits a single custom rendererId:"table" field (TablePayload exported; Name|Version|Trust|Activation, all trust tiers). CR-2: SurfaceCatalogEntry.scope?: "global"|"conversation" on both surfaces. Contracts: ui-contract 0.1.0→0.2.0, transport-contract 0.8.0→0.9.0 (additive). 907 tests pass (+13); live-verified against bin/up (warms @5s with future nextWarmAt; mid-turn close → abortedTurn:true + done.reason aborted). Courier: frontend-cache-warming-lifecycle-handoff.md.
2026-06-12feat(turns): detached turns + multi-client live viewAdam Malczewski
A turn no longer dies when its WebSocket connection closes. The turn-broadcast hub moves into the core (session-orchestrator): turns run detached, persist at seal regardless of clients, and fan out AgentEvents to N subscribers per conversation with in-flight buffer replay for late-joiners. transport-ws stops aborting turns on socket close and gains chat.subscribe/chat.unsubscribe so a second device (or a reloaded browser) can watch a running turn. - @dispatch/transport-contract 0.6.0->0.7.0: chat.subscribe/chat.unsubscribe WS ops - session-orchestrator: startTurn/subscribe/isActive; persistent subscribers + per-turn buffer (two-map model); handleMessage = convenience wrapper (no signal) - transport-ws: per-connection chat-subscription fan-out; no turn-abort-on-close - transport-http: test fakes updated for the widened interface (runtime unchanged) - design notes/turn-continuity-design.md; FE courier frontend-turn-continuity-handoff.md Live-verified vs flash (2-client WS): sender disconnect mid-turn -> other client streams to done + turn persists; late-join replays turn from turn-start. 891 vitest + transport bun green; tsc -b EXIT 0; biome clean.
2026-06-11feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming ↵Adam Malczewski
cache bust LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block — the first bytes of the prompt-cache prefix — diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs §3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
2026-06-11fix(cache-warming): accurate cache rate + expectedCacheRate (retention) metricAdam Malczewski
The Claude cache % read 100% whenever anything was cached, because the metric's denominator (inputTokens) excluded cached tokens on Anthropic. Fixed upstream in ../claude/provider-anthropic (inputTokens = total prompt); this commit adds the companion retention metric and exposes it: - transport-contract: WarmResponse += expectedCacheRate - transport-http: POST /chat/warm returns expectedCacheRate = cacheRead/(cacheRead+cacheWrite) - cache-warming: computeExpectedCacheRate + a per-conversation 'cache retention' surface stat - handoff: documents the fix + cache-rate vs expected-cache (cross-turn) for the FE Live-verified vs claude haiku: real turn cache rate 61% (was inflated 100%); warm within TTL expectedCacheRate=100%, after expiry=0%.
2026-06-11feat(cache-warming): manual POST /chat/warm trigger endpointAdam Malczewski
A frontend 'warm now' button (and fast tests) can trigger a warm on demand instead of waiting for the automatic timer. - transport-contract: WarmRequest / WarmResponse wire types - transport-http: POST /chat/warm → cacheWarmHandle.warm(); 200 with cachePct, 409 when the conversation is generating, 400 on missing conversationId Live-verified vs claude haiku: seed turn cacheWrite=6799 → POST /chat/warm returns cacheReadTokens=6799 cachePct=100 (100% hit). 760 vitest + 109 bun green.
2026-06-10feat: per-model throughput (tok/s) tracking + metrics endpointAdam Malczewski
New throughput-store extension records one token-weighted sample per turn (model, output tokens, pure generation time = Σ step genTotalMs) into a day-bucketed KV store, and aggregates per-model tok/s = Σtokens / Σgen-seconds over a day/week/month (server-local boundaries; week = ISO Mon–Sun). transport-http records a sample per turn (logged) and serves GET /metrics/throughput?period=day|week|month&date=<...>. The response is typed as transport-contract's ThroughputResponse, so store/wire drift is a compile error. Pure period + aggregate logic fully unit-tested.
2026-06-10feat(metrics): durable per-turn/step token+timing metrics (observability ↵Adam Malczewski
spans + persisted replay) Two-part token-data improvement: #2 Observability spans (kernel run-turn): turn & step span-close now stamp ALL four Usage fields — added usage.cacheReadTokens/cacheWriteTokens (were silently dropped) and normalized usage_* -> usage.* to match the provider.request span (consistent D9 GROUP BY). No contract change. #3 Persisted replay metrics (conversation-store + read endpoint): new StepMetrics/TurnMetrics wire types; conversation-store persists per-turn metrics in a separate key space (appendMetrics/loadMetrics, turn-append order); session-orchestrator accumulates per-step+turn metrics from the event stream (pure metrics.ts) and persists after seal; transport-http serves GET /conversations/:id/metrics -> ConversationMetricsResponse. Contracts: @dispatch/wire + @dispatch/transport-contract bumped 0.3.0->0.4.0 (additive). GLOSSARY: turn metrics / step metrics. typecheck EXIT 0, biome clean, 546 vitest + 89 bun = 635 tests.
2026-06-06feat(transport-ws,transport-contract): multiplex chat ops onto the surface WSAdam Malczewski
Add chat WS ops (chat.send / chat.delta / chat.error) + unified WsClientMessage/WsServerMessage unions to @dispatch/transport-contract (imports ui-contract; surface protocol unchanged — additive non-colliding type variants, no channel wrapper). transport-ws drives sessionOrchestrator.handleMessage, streaming each AgentEvent as chat.delta over the same connection that carries surface ops; per-connection AbortController cancels in-flight turns on socket close; error-isolated. Verified live: one WS connection delivered the surface catalog AND a real flash chat turn (chat.delta stream, reply 'Hello my friend'). Completes the FE Slice 2 backend prereqs. typecheck clean, 485 vitest + 80 bun, biome clean. Discovered (separate, pre-existing): runtime does not emit turn-start/done/turn-sealed on either transport — needed for FE cache-commit; tracked in tasks.md.
2026-06-06feat(transport-http): GET /conversations/:id?sinceSeq= read-side history ↵Adam Malczewski
endpoint Incremental rehydration endpoint for long-lived clients. Returns ConversationHistoryResponse { chunks: StoredChunk[], latestSeq } — the RAW, append-order, seq-filtered slice from conversation-store.loadSince, NOT reconciled (reconcile conflicts with the per-chunk seq cursor, so it stays on the turn path; the read path is a pure sync primitive). - transport-contract: add ConversationHistoryResponse + StoredChunk re-export. - transport-http: GET /conversations/:id route reaching the log directly via conversationStoreHandle (dependsOn conversation-store); pure parseSinceSeq (absent->0, invalid->400). - build wiring: conversation-store dep + project ref. FE Slice 2 backend prereq (read-side). typecheck clean, 481 vitest, biome clean.
2026-06-06feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only ↵Adam Malczewski
split (B2) FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked. B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime. tsc -b + biome clean; 460 vitest + 77 bun pass.
2026-06-05feat(kernel): listModels/ModelInfo + per-turn cwd contracts; add ↵Adam Malczewski
transport-contract wire package