summaryrefslogtreecommitdiffhomepage
path: root/packages
AgeCommit message (Collapse)Author
3 daysfix(kernel): disable MAX_STEPS limit (0 = unlimited)Adam Malczewski
Agents were being cut off mid-task at 50 steps. The MAX_STEPS=50 hardcoded limit was silently terminating turns while the model was actively making tool calls, leaving conversations idle with a dangling tool-result as the last chunk. Setting MAX_STEPS to 0 disables the limit — the loop runs until the model stops making tool calls naturally or the abort signal fires. The max-steps code path is preserved for when MAX_STEPS > 0.
4 daysfix(ssh): POST /computers/:alias/test hangs after successful SSH connectAdam Malczewski
The test endpoint's runProbe() waited for the ssh2 stream's 'close' event, which some SSH servers never emit for short-lived exec channels (the command 'true' exits instantly). This caused the promise to hang forever — the HTTP response never returned, and the FE's Test spinner spun indefinitely. Three fixes: 1. runProbe now resolves on the 'exit' event (not 'close') — the command has finished and the exit code is available. 'close' is kept as a fallback. Stream data/stderr are drained to prevent buffer deadlocks. 2. runProbe has a 15s timeout safety net — if the exec callback or 'exit' event never fires (e.g. server requires a pty for exec), the probe resolves false instead of hanging forever. 3. The entire test() method is wrapped in a 30s Promise.race timeout — even if pool.acquire() or pool.drop() hangs, the endpoint ALWAYS responds with { ok, error? }. The probe is fully non-interactive (no blocking prompts). tsc EXIT 0, biome clean, 1756 tests pass.
4 daysfeat(ssh): discover computers from ~/.ssh/known_hosts + remote system-promptAdam Malczewski
Two improvements to the SSH support feature: 1. KNOWN_HOSTS DISCOVERY (packages/ssh): Computers are now auto-discovered from ~/.ssh/known_hosts (every hostname you've ever connected to) in ADDITION to ~/.ssh/config (explicit Host aliases). Config entries take precedence (full params); known_hosts entries get defaulted params (User=defaultUser, IdentityFile=null→pool probes default keys, Port from [host]:port or 22, knownHost=true). Zero-config — no ~/.ssh/config file needed; hosts just appear. Reject list: dispatch.toml [ssh].reject = [...] (glob patterns like github.com, *.ts.net) filters noise from the catalog. Read from both the global ~/.config/dispatch/dispatch.toml and the project dispatch.toml. Parsed with Bun.TOML.parse (zero deps). Only filters discovery (catalog); specific lookups (getComputer/getStatus/test/connect) ignore the reject list (it's a visibility filter, not access control). New pure functions: parseKnownHosts(), isRejected(), globMatch(). +26 tests. tsc EXIT 0, biome clean, 1756 tests pass. 2. REMOTE SYSTEM-PROMPT AWARENESS (packages/system-prompt): When a conversation has a computerId set (remote turn), the system prompt now resolves system:os, system:hostname, git:branch/git:status, and file: reads against the REMOTE machine — not the local host. Previously the prompt always said 'Arch Linux (WSL)' + local hostname even when the agent was connected to a remote Artix Linux machine. The ResolverAdapters' hostname()/platform() are now async (so a remote adapter can run 'hostname'/'uname -s' over SSH). The system-prompt extension builds remote adapters from the ExecBackend (readFile→SFTP, spawn→SSH exec). Cache invalidation now checks computerId (switching computers rebuilds the prompt). The compaction path also threads computerId. @dispatch/system-prompt now depends on @dispatch/exec-backend.
4 daysMerge branch 'dev' into feature/ssh-supportAdam Malczewski
Brings dev's retry-with-backoff (the transient `provider-retry` AgentEvent the web frontend consumes) + the LSP-dead-server per-edit-hang fix into the SSH feature branch, alongside the SSH waves 0-5c. All code files auto-merged cleanly (run-turn.ts, orchestrator.ts, runtime.ts, wire/index.ts, tool-edit-file/extension.ts, run-turn.test.ts — both computerId threading and retry-with-backoff coexist). Only tasks.md conflicted (status section — orchestrator-resolved; both feature sections kept). Verified post-merge: tsc -b EXIT 0, biome clean (391 files), 1730 vitest pass +6 sshd-integration skipped (was 1690; +40 from dev's retry/LSP tests). Wire dist rebuilt so the FE can re-sync the pinned @dispatch/wire dep and pick up BOTH provider-retry AND the SSH Computer/defaultComputerId types. No merge or push (into dev or otherwise).
4 daysMerge branch 'feature/lsp-bugfix' into devAdam Malczewski
4 daysfix(lsp): stop per-edit hangs on dead/slow servers (10s cap + skip + self-heal)Adam Malczewski
The LSP diagnostics path hung up to 60s per edit whenever a configured Ruby language server was dead or slow (the reported Steep langserver case): a killed/crashed server was never detected (stayed "connected" forever), servers were queried sequentially with a 60s budget each, and a corrupted-but-alive server (Steep's ~3h phantom-SyntaxError drift) had no recovery. Four fixes, all in packages/lsp/ (the tool-edit-file call site lowered to 10s): 1. Dead-process detection: SpawnedProcess.onExit (Bun proc.exited) + stdout-end defence flip the client to error, dispose the rpc, kill the proc. The manager re-spawns a fresh server after the 30s backoff. Dead servers are now skipped (0s) instead of polled for 60s. 2. Concurrent fan-out + 10s hard cap: new aggregateDiagnostics queries all matching servers at once, each capped at 10s. A non-responder is skipped with "LSP took too long (>10s), skipped — raise this to the user" instead of blocking the fast server's results. Replaces the vague "unusually long" warning (now structurally impossible: slow is always false). 3. Corruption self-heal: a detector flags a server re-emitting identical non-empty diagnostics despite the file changing; after 5 repeats the client is marked broken and re-spawned. Clean files never trip it. (Acknowledged false-positive risk on persistent unfixed errors; CLI type-check gate stays authoritative.) 4. sendRequest timeout: hover/definition/references cap at 10s so they can't hang the turn against a dead server; the initialize handshake keeps its 45s race. Verification: typecheck clean; 1573 tests pass (96 files), +15 new LSP tests (86 in packages/lsp); biome clean. No kernel/contract changes; onExit is internal to packages/lsp.
4 daysfeat(kernel): retry-with-backoff on retryable provider errorsAdam Malczewski
When the upstream LLM API returns a retryable error (HTTP 429 / 5xx "overloaded"), the kernel now retries provider.stream() with a stepped backoff, visibly, until the 8h cumulative-sleep budget is exhausted — then emits the final error and seals the turn. Retries fire only when no content was emitted yet this step (safety invariant: never duplicate partial output). - wire: new transient TurnProviderRetryEvent AgentEvent variant (emitted before each sleep; not persisted to model history). - kernel contracts: RetryStrategy (pure delayFor + injected sleep) + optional retry? on RunTurnInput (omit = no retry, backward-compatible). - kernel run-turn: retry loop in executeStep; providerRetryEvent constructor. Kernel imports no timer (sleep injected). - session-orchestrator: concrete schedule (5s..30m, repeat 30m, 8h budget) + abortable setTimeout sleep, wired into RunTurnInput.retry. tsc -b EXIT 0; biome clean; 1574 vitest pass (+16 new: 11 kernel retry tests with injected fake sleep + pure delayFor, zero @dispatch/* mocks; 5 schedule tests). Transports unchanged (transport-ws forwards AgentEvent verbatim in chat.delta; transport-http is generic JSON.stringify). Plan: notes/retry-with-backoff-plan.md. tasks.md updated with milestone + optional CLI-renderer roadmap follow-up.
4 daysfeat(ssh): wave 5c — host-bin registers exec-backend + ssh; transport-http ↵Adam Malczewski
barrel Wave 5c (final wiring) of transparent SSH support. - host-bin: register exec-backend + ssh in CORE_EXTENSIONS (exec-backend before the tool extensions that dependsOn it; ssh after, provides the remote-backend factory + ComputerService at boot). +@dispatch/exec-backend/@dispatch/ssh deps + tsconfig refs. - transport-http: CR-5 — re-export computerServiceHandle + ComputerService type from the package barrel (src/index.ts), mirroring lsp/mcp handles, so ssh imports the typed symbol cleanly (no more dist/seam.js subpath workaround). - orchestrator: added the @dispatch/exec-backend dep the host-bin agent missed + bun install. LIVE-VERIFIED: bun packages/host-bin/src/main.ts boots clean ('Dispatch booted', no disabled extensions) — exec-backend + ssh + all tool extensions load together. Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (+6 sshd-integration skipped). DEFERRED (CR-6): listComputers usageCount stays 0 until a conversation-store count-by-alias helper is added (non-blocking). Refs: notes/ssh-support-plan.md. No merge or push.
4 daysfeat(ssh): wave 5b — the ssh package (remote ExecBackend over ssh2)Adam Malczewski
Wave 5b of transparent SSH support. NEW standard extension @dispatch/ssh makes remote execution actually work over SSH, transparently. ssh2 verified to run under Bun (load-bearing decision #1 confirmed: connects to local sshd :22 + execs). - config.ts: ~/.ssh/config reader via ssh-config -> Computer[]/ComputerEntry[] (read-only discovery; resolves hostName/port/user/identityFile/knownHost). - hostkey.ts: known_hosts auto-trust-and-pin (present->verify/reject-on-mismatch, absent->accept+append; the accept-new analog). - errors.ts: pure ssh2/SFTP -> node:fs-style .code error mapping (so tools' existing ENOENT branches work unchanged). - pool.ts: SshConnectionPool (per-alias ssh2.Client, lazy connect, keep-alive, idle reap ~15m); key-only auth from ~/.ssh (config IdentityFile or default id_ed25519/id_rsa); no agent-forwarding, no PTY. - backend.ts: SshExecBackend implements ExecBackend (spawn via client.exec with shell-quoted cwd; fs via SFTP). - service.ts + extension.ts: activate provides BOTH handles the other units consume — remoteExecBackendFactoryHandle (exec-backend: computerId->SshExecBackend) AND computerServiceHandle (transport-http: listComputers/getComputer/getStatus/test). - orchestrator: added packages/ssh to root tsconfig.json refs + bun install. Tests: 45 pass + 6 sshd-integration skipped (it.skipIf(!process.env.SSH_TEST_HOST)). Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (was 1641, +49). CRs for wave 5c: host-bin registration; CR-5 transport-http barrel re-export; CR-6 usageCount wiring (deferred-ok, defaults to 0). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
4 daysfeat(ssh): wave 5a — exec-backend remote-backend factory handleAdam Malczewski
exec-backend declares remoteExecBackendFactoryHandle (a consumer-defined ServiceHandle<(computerId) => ExecBackend>) that the ssh package will provide (standard→core layering). The resolver's computerId-set branch now lazy-looks-up this factory (at tool-execute time, runtime) and calls it; if ssh isn't loaded, getService throws → a clear 'SSH remote execution is not configured' error. The computerId-undefined (local) branch is byte-identical to before. This is the seam wave 5b (the ssh package) plugs into. +tests for both branches. Verified: tsc -b EXIT 0, biome clean. No merge or push.
4 daysfeat(ssh): wave 4 — computer HTTP/WS endpoints + chat computerId threadingAdam Malczewski
Wave 4 of transparent SSH support (3 parallel owner-agents on disjoint packages). - transport-http: computer routes — GET /computers, GET /computers/:alias, GET /computers/:alias/status, POST /computers/:alias/test (all delegate to a new ComputerService seam, graceful []/disconnected when ssh not loaded); GET/PUT/DELETE /conversations/:id/computer; PUT /workspaces/:id/default-computer (mirror the cwd/default-cwd routes); /chat threads computerId into the orchestrator. Defines ComputerService interface + computerServiceHandle (defineService<ComputerService>('ssh')) in seam.ts — the seam the ssh package provides via host.provideService in wave 5. - transport-ws: chat.send + chat.queue thread computerId onto the route result (mirrors cwd/workspaceId), forwarded to the orchestrator input. - mcp: CR-1 fix — filterMcpTools now preserves computerId on the returned ToolAssembly (mirrors cwd preservation), so the filter chain stays consistent. - orchestrator: added @dispatch/wire dep to transport-http (build/config, my lane) so its seam.ts Computer/ComputerEntry import resolves. Verified: tsc -b EXIT 0, biome clean, 1641 vitest pass (was 1620, +21). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
4 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.
4 daysfeat(ssh): wave 2 — route filesystem/shell tools behind ExecBackendAdam Malczewski
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint tool packages). The tools now resolve an ExecBackend per-call from ctx.computerId and call backend.spawn / backend.readFile / etc. instead of node:fs and node:child_process directly — so they are transport-agnostic (local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY this wave (computerId always undefined -> LocalExecBackend, behavior-identical). - tool-shell: factory takes resolveBackend; execute calls backend.spawn. spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's LocalExecBackend.spawn — logic moved to the sanctioned shared package). manifest dependsOn:[exec-backend]; host.getService at activation. - tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched; ENOENT .code branches kept). - tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched). - tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1; local path byte-identical to today). LSP lookup stays lazy. - orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool package.jsons + bun install (build/config, my lane) so isolated verify resolved cleanly; agents added the ../exec-backend tsconfig ref. Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
4 daysfeat(ssh): wave 1 — ExecBackend + computer data model + runtime threadingAdam Malczewski
Wave 1 of transparent SSH support (parallel owner-agents on disjoint packages, plus the orchestrator-authored kernel contract seam from wave 0): - packages/wire: + Computer/ComputerEntry (read-only view over ~/.ssh/config Host aliases) + Workspace.defaultComputerId (string|null, null=local). Types only; 3 conformance tests. - packages/exec-backend (NEW core extension): the ExecBackend abstraction (spawn + minimal fs surface) the bundled tools will program against instead of node:fs/child_process. LocalExecBackend wraps today's node calls (behavior-identical; node:fs-style .code errors). execBackendHandle + ExecBackendResolver (sync; computerId undefined -> local; set -> throws until the ssh package wires remote resolution in wave 5). 20 tests. - packages/kernel (runtime only): thread computerId through dispatch.ts + run-turn.ts exactly as cwd is threaded (opaque, forwarded to ToolExecuteContext; absent = local = byte-identical to today). +2 tests. - packages/conversation-store: computer (SSH alias) assignment + resolution mirroring cwd — WorkspaceRow.defaultComputerId + setWorkspaceDefaultComputerId + getComputerId/setComputerId/clearComputerId + getEffectiveComputer (override -> per-conv -> workspace default -> null/local). Fixes the 3 Workspace literal sites the new required wire field broke. +18 tests. - orchestrator: root tsconfig.json ref for exec-backend + bun install. Verified: tsc -b EXIT 0, biome clean, 1592 vitest pass (was 1549, +43). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
4 daysfeat(ssh): wave 0 — kernel contract seam (computerId)Adam Malczewski
Add additive optional `computerId` field to ToolExecuteContext + RunTurnInput. The kernel never interprets it (forwards verbatim to tools, like cwd) — it never enters the model prompt (no prompt-cache impact). When omitted/undefined, execution is LOCAL (today's behavior), so this is fully backward compatible. This is the orchestrator-authored seam (ORCHESTRATOR.md §2a) that lets Wave 1's producers (wire Computer types, exec-backend contract) and the consumer (kernel runtime threading) run in parallel against a fixed type. Refs: notes/ssh-support-plan.md (decisions resolved in §0.5/§13). No merge or push.
4 daysfeat(cli): add --workspace filter to 'dispatch list'Adam Malczewski
The backend already supported GET /conversations?workspaceId= but the CLI never sent it. Wire the list command to that filter: - args.ts: parse --workspace / -w on 'list' (placed before the --catch-all so the single-dash -w shorthand isn't taken for a positional prefix); add workspaceId? to the list ParsedCommand. - http.ts: add workspaceId? to FetchConversationsOpts; send ?workspaceId= (after q/status, preserving URLSearchParams order). - main.ts: forward parsed.workspaceId into fetchConversations; update USAGE. Composable with --status and the <prefix> short-id arg. 'Open conversations in workspace X' is now: dispatch list --workspace X (status defaults to active,idle). No contract changes — purely additive CLI wiring. Tests: +4 args (incl. composability + missing-value error), +2 http (exact ?workspaceId= URL + combined status/workspaceId with %2C encoding). typecheck EXIT 0, biome clean (364 files), full suite 1558 passed. Live-verified against an isolated server.
4 daysfeat(cli): add --file flag to 'dispatch send' subcommandAdam Malczewski
Add the same --file <path> support that the summon (chat) command has to the 'dispatch send' subcommand. When --file is given, the file's contents are read and attached to the message (composed via composeMessage, identical to chat). - args.ts: add 'file' to the send ParsedCommand, make 'text' optional, parse --file, and require at least one of --text or --file. - main.ts: read the file and compose the message in the send case, using the composed message in both the --queue and streaming branches; update USAGE. - args.test.ts: cover --file parsing (alone, with --text, missing value) and update the existing send expectations + the both-missing error message.
4 daysfeat(transport-http): add GET /conversations/:id/mcp status endpointAdam Malczewski
Mirrors the existing GET /conversations/:id/lsp route exactly: gates on the persisted then effective cwd (null → empty servers), returns 503 when the MCP service isn't loaded, and maps McpServerStatus → McpServerInfo (conditionally including `error` per exactOptionalPropertyTypes). Wires mcpService into CreateServerOptions + extension activate via a plain host.getService (mirroring lspService; "mcp" added to dependsOn, route added to contributes.routes), adds the @dispatch/mcp workspace dep, and re-exports mcpServiceHandle / McpService / McpServerStatus from seam.ts. Adds 4 tests mirroring the LSP status tests.
4 daysfix(lsp): prevent server crash from malformed LSP messagesAdam Malczewski
Two bugs caused the dispatch server to crash (15 times since Jun 24) when chat cc6c edited packages/transport-http/src/app.ts — a 40KB file with 23 multi-byte UTF-8 lines. The edit_file diagnostics hook sends the file to tsserver, which sends back a large publishDiagnostics response. When the response was split across stdout chunks at a multi-byte character boundary, the server crashed. Layer 1 — rpc.ts handleMessage: JSON.parse had no try/catch. A corrupted message threw an unhandled SyntaxError → unhandled rejection → process exit. Wrapped in try/catch; malformed messages are now skipped. Also hardened client.ts handleBytes: the async handleMessage Promise was fire-and-forget. Added .catch(() => {}) as defence-in-depth so no rejection from the RPC layer can ever crash the server. Layer 2 — framing.ts FrameDecoder: used a string buffer with new TextDecoder().decode(chunk) (no { stream: true }), corrupting multi-byte characters split across chunks. Worse, Content-Length counts bytes but the buffer was sliced by character count — for multi-byte content byte length ≠ char length, so the decoder extracted the wrong slice as a message. Rewrote to use a Uint8Array byte buffer: header separator search is byte-level, Content-Length comparison is byte-level, and the body is decoded only after all bytes are confirmed present. Tests: 5 new multi-byte framing tests (split at char boundary, byte-vs-char Content-Length, two messages in one chunk, three-way split) + 1 rpc test (malformed JSON does not throw). All 1545 tests pass.
5 daysfeat(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).
5 daysfix(tool-edit-file): lazy LSP service lookup — diagnostics now actually workAdam Malczewski
The previous fix (e03a96e) wrapped getService in try/catch to prevent the activation crash, but that wasn't enough: tool-edit-file activates at position 5 in CORE_EXTENSIONS while lsp activates at position 20. So getService ALWAYS threw at activation time, lspService was ALWAYS undefined, and the diagnostics hook was NEVER wired — edits succeeded but never showed LSP feedback. Fix: make the LSP service lookup LAZY — defer it to edit time (when the tool is actually called), not activation time. By then all extensions have activated. The diagnostics function tries getService on each edit call; if LSP isn't loaded, it returns a no-op (graceful degradation).
5 daysfix(tool-edit-file): wrap getService in try/catch to prevent activation crashAdam Malczewski
The per-edit diagnostics change (8f6114b) called host.getService(lspServiceHandle) during activate(). But getService THROWS when a service has no provider — so if the LSP extension activates AFTER tool-edit-file (or isn't loaded at all), the activate() function crashes and the edit_file tool is NEVER REGISTERED. This is why the edit_file tool was missing from the agent toolset. Fix: wrap getService in try/catch — if the LSP service isn't available yet, lspService becomes undefined and edits proceed without diagnostics (the graceful degradation the comment always promised but the code didn't deliver).
5 daysfeat(mcp): Model Context Protocol host extensionAdam Malczewski
New `mcp` standard extension (`packages/mcp/`) that makes Dispatch an MCP host: spawns configured MCP servers (stdio child processes), performs the MCP handshake (initialize → notifications/initialized), discovers tools via tools/list, and registers each as a first-class Dispatch ToolContract via host.defineTool. When the model calls an MCP tool, the extension proxies the call to tools/call on the MCP server and returns the flattened result. Architecture (sibling of `lsp` extension): - Config: .dispatch/mcp.json (servers key) → opencode.json mcp key fallback, resolved per-cwd (mirrors LSP config resolution) - Transport: StdioTransport (spawn child, Content-Length framing + JSON-RPC 2.0) - Client: initialize → tools/list → tools/call; handles list_changed notifications for dynamic tool updates - Registry: tool name namespacing (<serverId>__<toolName>), ToolContract adapter that proxies execute → callTool, content flattening (text/image/ resource → string) - Manager: one client per server, lazy-spawn, status(), shutdownAll() - Extension: manifest (dependsOn session-orchestrator, capabilities spawn), registers tools + a toolsFilter (drops disconnected server's tools), mcpServiceHandle, deactivate kills all child processes Phase 1 scope: stdio only, Tools only (no Resources/Prompts/HTTP/sampling). Hand-rolled JSON-RPC + framing (zero external deps, adapts LSP patterns). Wave 1 (agent): 12 source + 8 test files, 69 new tests. Wave 2 (orchestrator): root tsconfig ref, host-bin CORE_EXTENSIONS registration + package.json dep, bun install. Verified: tsc -b EXIT 0, biome clean, 1537 vitest pass (was 1468, +69).
5 daysfeat(lsp+tool-edit-file): multi-server diagnostics + per-edit auto-appendAdam Malczewski
LSP extension: - Multi-server aggregation: query ALL connected servers matching the file's extension (not just the first), merge diagnostics tagged by source - Incremental sync: capture each server's textDocumentSync.change during initialize; compute prefix/suffix diff ranges for change:2 servers; full content for change:1 (generic, works for any LSP) - New diff.ts: pure computeChangeRange + offsetToPosition (O(n), tested) - Buffer sync: change(filePath, newText) sends didChange with post-edit in-memory content; openWithText for first open; tracks open doc text - languageId mapping: extended with .rb/.rbs/.c/.cpp/etc. (was 'unknown') - waitForDiagnostics: accepts text override + timeoutMs; returns { formatted, slow, timedOut }; polls for publishDiagnostics push - DiagnosticsStore: hasReceivedPush/clearReceived tracking; formatFiltered with minSeverity (1=Error, 2=Warning) for edit_file integration - LspService.getDiagnostics: service method for cross-extension use tool-edit-file: - After successful edit, calls LSP getDiagnostics with post-edit buffer - Only appends diagnostics with severity ≤ 2 (errors+warnings, no noise) - Appends slow warning (>10s): 'LSP is taking unusually long...' - 60s timeout; graceful degradation when no LSP available - Optional dep on @dispatch/lsp (getService pattern, not manifest depOn) 1468 vitest pass (was 1453, +15 new diff tests).
5 daysfix(kernel+tool-shell): abort hanging tool calls without bricking the ↵Adam Malczewski
conversation kernel: executeToolCall now races tool.execute against the abort signal via Promise.race; on abort resolves (not rejects) with an "Aborted" result so the step completes normally → finishReason "aborted" → turn seals cleanly (done event) → finally clears activeTurns → conversation freed, next message accepted. run-turn strips tool-call chunks from the assistant message on abort (keeps text/thinking) and omits tool-result messages to avoid persisting dangling tool calls that would 400 the provider next turn. tool-shell: realSpawn spawns detached (own process group); on abort AND timeout kills the entire group (process.kill(-pgid, SIGKILL)) and resolves immediately — no child.on("close") dependency, so a grandchild holding the pipes can't stall the spawn promise or leak. Also: ORCHESTRATOR.md migrated to dispatch CLI summon mechanism; .skills summary; bin/sync-env PATH injection; frontend handoff docs. 1453 vitest pass · tsc -b EXIT 0 · biome clean.
5 daysfix(broken-chat): read-time self-repair of unrecoverable chatsAdam Malczewski
reconcile() only repaired orphaned tool-calls. Two other broken states made chats uncontinuable, and load() had no parse-error guard: - A trailing assistant message whose only chunk is 'error' (a failed- generation marker) serializes to empty content -> provider rejects/empty -> chat never continues. 6 of 140 production conversations were stuck. - A tool-call whose input is a raw malformed-JSON string (model emitted broken JSON) re-sent as OpenAI arguments -> provider 400s on every continuation (the 77574596 break). - load() JSON.parse had no try/catch -> one corrupt row bricked the chat. Fix = read-time repair (no DB surgery; append-only preserved). reconcile runs on every load() BEFORE any provider sees messages, so Layer 1 protects ALL providers. Layer 1 (conversation-store reconcile): strip error chunks from assistant messages + drop the now-empty error-only messages (safe: never followed by a tool message); orphaned-tool-call synthesis unchanged; ReconcileReport +2 additive counts. loadSince (FE reads) intentionally unreconciled so the user still SEES the error. load() wraps JSON.parse in try/catch (skip corrupt rows). Layer 2 (openai-stream): serializeToolArguments ensures tool-call arguments is always valid JSON (malformed string -> fallback object), neutralizing already-stored malformed args. Layer 2 equiv (../claude provider-anthropic): safeJson returns a valid object fallback on parse failure, not the raw string. (Separate repo.) Live-verified: reproduced 77574596's real broken tail in the dev DB; POST /chat continued it cleanly (no 400, model replied) — the provider accepted the reconciled history. tsc -b EXIT 0, biome clean, 1453 vitest pass.
5 daysfix(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.
5 daysfeat: 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.
5 daysfix(system-prompt): reconstruct on cwd change via getWithMetaAdam Malczewski
The system-prompt service cached the resolved prompt on first turn and reused it on subsequent turns via get(). But the prompt is cwd-sensitive (file:AGENTS.md, prompt:cwd variables). When a conversation's cwd changed after the first turn, the cached prompt was stale — referenced files from the new cwd were not loaded. system-prompt: added getWithMeta(conversationId) returning { prompt, cwd } and stores resolved-cwd:<id> alongside resolved:<id> in construct(). session-orchestrator: subsequent turns now call getWithMeta, compare stored cwd vs effective cwd, and reconstruct if they differ. Compaction path (always constructs) and warm path (no system prompt) are unaffected. 1411 vitest pass; tsc + biome clean.
5 daysworkspace: 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
5 daysfeat(system-prompt): rich system:os with WSL detection + Linux distroAdam Malczewski
system:os now returns a descriptive string instead of the raw platform: - Linux: reads /etc/os-release for distro name (PRETTY_NAME or NAME+VERSION_ID) - WSL detection: checks /proc/sys/fs/binfmt_misc/WSLInterop or 'microsoft' in /proc/version — appends (WSL) to the distro string - Non-Linux: returns process.platform as-is (darwin, win32, etc.) Examples: 'Ubuntu 22.04 LTS', 'Ubuntu 22.04 LTS (WSL)', 'Debian 12', 'Linux (WSL)', 'darwin'. All file reads use injected fs adapters (testable). 7 new resolver tests. 1403 vitest pass. FE CR-9.
5 daysfeat(system-prompt): add prompt:workspace_id variableAdam Malczewski
Lets the AI know which workspace it's in — especially useful when summoning agents. Wired through the construct context in both the regular turn flow and the compaction flow.
5 daysfeat(system-prompt): register extension in host-bin CORE_EXTENSIONSAdam Malczewski
Register @dispatch/system-prompt in CORE_EXTENSIONS (after skills, before cache-warming). Add dep + tsconfig ref. 1396 vitest pass, typecheck + biome clean.
5 daysfeat(system-prompt): wire into turn flow + compaction + API routesAdam Malczewski
session-orchestrator: - Wire systemPromptService as optional dep (lazy via host.getService) - Regular turn: construct on first turn (new conversation), get on subsequent turns, set on providerOpts.systemPrompt (cache-safe) - Compaction: construct (fresh resolve) + append COMPACTION_SYSTEM_PROMPT - 12 new tests (construct/get/service-unavailable/compaction) transport-http: - GET /system-prompt (returns template or DEFAULT_TEMPLATE) - PUT /system-prompt (validate + setTemplate, 503 when unavailable) - GET /system-prompt/variables (static catalog, always available) - 6 new tests system-prompt service: added getTemplate/setTemplate to interface + impl. 1396 vitest pass. typecheck + biome clean.
6 daysfeat(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.
6 daysfix(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.
6 daysfeat: workspaces — session-orchestrator + transport-http + transport-ws + ↵Adam Malczewski
cli (Wave 2+3) session-orchestrator: workspaceId on StartTurnInput/EnqueueInput; effective cwd resolution (getCwd → getEffectiveCwd); auto-create workspace on turn start; warm parity (same effective cwd). 93 tests (+8). transport-http: workspace routes (GET/PUT/DELETE /workspaces, title, default-cwd); workspaceId threading on POST /chat + queue; ?workspaceId= filter on GET /conversations; DELETE /conversations/:id/cwd (clears explicit cwd); GET /conversations/:id/lsp uses effective cwd; slug validation. 166 tests. transport-ws: workspaceId threading on chat.send + chat.queue. 32 tests. cli: --workspace/-w flag; ConversationMeta test fakes fixed. 123 tests. Full typecheck EXIT 0, biome clean. 1283 vitest + 199 transport bun pass (1 pre-existing tool-shell failure unrelated to workspaces).
6 daysfeat: 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).
7 daysfeat: 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
7 daysfeat: incremental seq assignment during generation (CR-6)Adam Malczewski
The backend now persists chunks at step boundaries during generation, not only at turn-seal. This enables the FE to syncTail mid-turn and pick up committed, seq'd chunks (eliminating the provisional state). Changes: - RunTurnInput: add onStepComplete callback (kernel contract) - runTurn: call onStepComplete after each step's messages are finalized - Orchestrator: persist userMsg at turn start + each step's messages via onStepComplete. Falls back to batch persist if callback isn't called (backward compatible with test fakes). The user message gets seq numbers before the first step generates. Each step's assistant + tool messages get seq numbers as they complete. The FE's existing syncTail (?sinceSeq=N) picks them up during generation. Also adds backend-to-fe-handoff.md with CR-6 response + full endpoint list.
7 daysfeat: stop generation mid-turn (POST /conversations/:id/stop)Adam Malczewski
Add stopTurn to the orchestrator: aborts the in-flight turn's AbortController without changing conversation status. The turn seals normally (finishReason: 'aborted'), partial messages are persisted, and the conversation transitions active → idle via the normal settle path. Distinct from closeConversation which marks the conversation closed. - POST /conversations/:id/stop endpoint - dispatch stop <id> CLI command - FE handoff: frontend-stop-generation-handoff.md
7 daysfix: 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.
7 daysfeat: 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
7 daysfeat: default auto-compact threshold to 350k tokensAdam Malczewski
When no compact-threshold is explicitly set on a conversation, the default is 350000 tokens. Setting threshold to 0 explicitly disables auto-compact.
7 daysfeat: 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
7 daysfeat: 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
8 daysfeat: remove CWD path containment from file toolsAdam Malczewski
read_file, write_file, and edit_file no longer restrict access to paths outside the working directory. The isPathWithinWorkdir prefix check and symlink hardening have been removed from all three tools. This allows agents to read and write files anywhere on the filesystem, not just within the per-turn cwd. The shell tool already had no such restriction.
8 daysfix(transport-http): stream /chat response instead of bufferingAdam Malczewski
The /chat endpoint was buffering the entire turn before returning the response, which meant X-Conversation-Id was not available until the turn finished. This prevented the CLI --open flag from firing until after the turn completed. Now the response is a ReadableStream that: - Returns X-Conversation-Id header immediately - Streams NDJSON events as they arrive from the orchestrator - Closes the stream when the turn completes (or errors) - Records throughput after stream close (non-blocking) This fixes: dispatch <model> --text '...' --open now opens the frontend tab immediately, not after the turn finishes.
8 daysfix(cli): fire --open signal before streaming starts, not afterAdam Malczewski
For both 'send' and 'chat' commands, the --open signal now fires immediately after the conversation ID is known (before stream consumption), so the frontend opens the tab right away instead of waiting for the turn to complete.
8 daysfeat(cli): add 'open' command to signal frontend without sending a messageAdam Malczewski
dispatch open <conversationId> broadcasts a conversation.open WS message to all connected frontend clients without sending any message. Useful after 'read' or 'send --queue' when you just want the frontend to open/focus a conversation's tab.