IDE protocol host — lattice as a Claude Code agent peer
Lattice speaks the Claude Code IDE protocol as the IDE side: an external claude CLI connects over loopback and drives the editor — reading the selection / open buffers / diagnostics, opening files, and proposing edits as interactive diffs the user Keeps or Rejects. The goal is to develop lattice from within lattice, reusing the diff system and terminal that already exist.
This is the lattice-native analog of claude-code-ide.el (Emacs) and of the VS Code / JetBrains integrations — it implements the same WebSocket + MCP contract those editors expose.
1. Peer protocol, not an extension substrate
lattice-claude-code is a network peer, architecturally identical in spirit to lattice-lsp (an LSP client): a loopback JSON-RPC connection to an external process. It runs no agent in-process, executes no third-party code in lattice's address space, and adds no scripting surface. It therefore does not violate "WASM is the only extension substrate — no Lua / vimscript / elisp" (that rule governs in-process foreign-code execution), and is orthogonal to the WASM plugin host (design.md Phase 7, unbuilt) — it depends on none of it.
2. Mode-owned, minimum host touch (Steer A)
lattice-claude-code is a mode that owns its full surface; the App stays a thin host exposing generic primitives (feedback_mode_owns_its_surface). The acid test — a new provider crate adds zero Editor:: methods and zero new Action variants — is met:
- Server + lifecycle: boot spawns the
ClaudeCodeServersupervisor task (idle until started) on the shared async runtime and registers itsClaudeCodeServerHandleintoServiceRegistry;register_claude_code_modesregistersclaude-code-modeinto theModeRegistry. The server is started / stopped by the:claude-code-*ex-commands (and, from I5, ensured running byclaude-code-mode'son_activate, with itsGuardstopping it on deactivation). - Commands (corrected from I0): the server-lifecycle ex-commands (
:claude-code-start/:claude-code-stop;:claude/:claude-sendland in I5/I6) are registered by the crate with bare dashed names — they resolve directly viaid_by_nameon the:line, so the command surface needs no host alias-table entry. Eachapplyclosure captures theClaudeCodeServerHandleand drives it directly (a non-blockingcmd_txsend), returning an existingEffect(Echo). This — not a modeActionHandler— is the mode-ownership-compliant route for ex-commands: the:line rejectsCommandKind::Action(excommand.rs), and an ex-commandapply(Fn(&ExCommandContext) -> Effect) is handed noservices, so it cannot reach theActionHandlerRegistry. Capturing the subsystem handle keeps BOTH the binding (the command name) AND the handler body in the crate, with no new hostEditor::method and no newEffectvariant. (A future chord-bound action would use theActionHandlerpath; ex-commands can't.) - Writes (
openFile/saveDocument/close_tab) reuse existing effects (Effect::OpenBufferAt, save, close-buffer) — no new host write path. - Reads (corrected — crate-owned, not host-owned): the read state + tool logic belong to
lattice-claude-code, NOT the host. The crate holds aClaudeCodeReadStatecache fed by subscribing to the generic event bus (DocumentOpened/DocumentClosed/SelectionsChanged) in the mode's lifecycle, and answers on-demand text / path / dirty / selection through the genericBufferStoreservice (handle_for(id)→Documentsnapshot reads, wait-free off the editor thread).getDiagnosticsreads a genericDiagnosticsQuery::for_uriextension;getWorkspaceFolderscomes from the server config. The host exposes ONLY generic primitives (event bus, buffer store, diagnostics query). An earlier draft proposed a host-implementedClaudeCodeReadService/HostClaudeCodeReadstrait — rejected as a mode-ownership violation (it would put the crate's surface in the host). The one host-side change for reads is a genericDiagnosticsQuery::for_urimethod any mode can reuse.
The single new host primitive is a generic tick-callback registry: any mode registers a per-tick drain closure. This is reusable infrastructure (it generalizes the host's existing hardcoded option_change_rx / lsp_log_event_rx / … drains, which are the smell), not an claude-code-specific touch point. claude-code-mode registers its ClaudeCodeInbound drain through it. Rejected alternatives: an claude-code-specific Editor::drain_inbound_claude_code (fails the acid test, no merit gain over the generic registry — UX and perf are identical) and event→Invocation routing (a fixed-at-subscribe-time invocation can't carry structured write payloads — path / range / contents — cleanly).
3. The IDE buffer is terminal-like (Steer B, everything-is-a-buffer)
The IDE surface — where claude runs — reuses the BufferKind::Terminal substrate (PTY-backed; claude is an interactive TUI that wants a PTY). The terminal's dual-mode is exactly the requested behavior and is a per-buffer property, not kind-branching (feedback_buffers_no_special_case):
- Normal mode: full vim interaction — motions, yank, scrollback navigation — like any other buffer.
- Insert mode: keystrokes pass through to the
claudePTY (terminal-insert), i.e. you type your prompt to the agent.
claude-code-mode is a minor mode layered on that terminal buffer — it does not introduce a new BufferKind (which would mean renderer kind-arms + buffer- registry plumbing, fighting both minimum-host-touch and no-kind-specific-logic). claude-code-mode adds: the protocol server, the headerline status row (running / port / connected — the async-buffer-status convention, project_async_buffer_status_in_headerline), and diff affordances. :claude spawns claude in a terminal buffer with the discovery env injected and activates claude-code-mode on it.
4. Wire contract (must match VS Code's, for CLI compatibility)
- Transport: WebSocket on
127.0.0.1, dynamic port 10000–65535. JSON-RPC 2.0; MCP protocol version"2024-11-05". - Discovery: lockfile
~/.claude/ide/<port>.lock(VS Code schema — pid, workspace folders, auth token, transport, ide name) and env varsCLAUDE_CODE_SSE_PORT=<port>+ENABLE_IDE_INTEGRATION=trueinjected into the IDE terminal's child env. - Auth: handshake header
x-claude-code-ide-authorization: <token>(token from the lockfile). Loopback bind + token gate are the security boundary. - MCP methods:
initialize,tools/list,tools/call,prompts/list(empty); emitnotifications/tools/list_changedpost-init. - Tools — reads:
getCurrentSelection,getOpenEditors,getWorkspaceFolders,getDiagnostics,checkDocumentDirty. writes:openFile,saveDocument,close_tab. blocking:openDiff. - Notifications:
selection_changed,at_mentioned,workspace/didChangeActiveEditor.
The exact lockfile JSON field set is provisional until verified against a live claude CLI (before the terminal-launch slice).
5. Three data paths (chosen for paramount #1 + #4)
- Reads — crate-owned.
lattice-claude-codekeeps aClaudeCodeReadStatecache fed by generic event-bus subscriptions (DocumentOpened/DocumentClosed/SelectionsChanged) plus on-demandBufferStoresnapshot reads (off the editor thread, wait-freeArcSwap— no actor round-trip);getDiagnosticsuses a genericDiagnosticsQuery::for_uri;getWorkspaceFoldersfrom the server config. Answered entirely on the WS task — zero tick involvement, no renderer round-trip, and crucially no claude-code trait in the host (mode owns its read surface; the host stays generic). This is the same cache I6'sselection_changednotification reads, so it's produced once. - Writes — an
ClaudeCodeInboundmpsc channel drained per-tick via the mode's registered tick callback; the drain applies the matchingEffectand resolves aoneshotreply.O(pending)try_recv, same bounded shape as LSP's existing inbound drains. All I/O + JSON parse happens off-thread on the WS task; only the bounded apply touches the editor thread. openDiff(blocking) — reuse the diff subsystem, but via a SECOND, host-drained bus, not the I3 write bus:openDiffis blocking AND its open is irreducibly&mut Editor+ lattice-diff types that can't cross theEffectboundary, so it mirrors BC.8d apply-edit. Alattice_diff::ProgrammaticDiffRequest(a generic diff-subsystem type, so the host references no IDE-peer internals) carriesold_file_path/new_file_contents/new_file_path/tab_name+ aoneshot<DiffOutcome>; it ridesProgrammaticDiffBus(built withboot.inbound_raw, registered as a service the IDE peer reads). The host drains it per tick and opens a side-by-side two-pane diff — original (left, baseline) / proposed (right) — by creating two in-memoryDocumentbuffers and reusingregister_pane_group_diff(so the left buffer is the baseline; noStaticSource/OnDiskSourceneeded), thenbind_completion(response). Both sides are editable (like:diffsplit); Accept writes the proposed side's live content, so a human can tweak the agent's proposal in the diff and have the tweaks persist. The agent'stools/callblocks on its own WS task awaiting the oneshot, which fires on:diff-accept/:diff-reject(or a close-tab cancel → the dropped sender surfaces as a reject). On Accept the host writes the proposed side toold_file_pathbefore firing the outcome (the review IS the save), so the reply isFILE_SAVED; Reject →DIFF_REJECTED. The diff recompute already runs inspawn_blocking; the renderer is never blocked — only the agent connection. (Reply shape PROVISIONAL until validated vs a live CLI.)
Graceful degradation is structural: every WS-thread failure logs-and-skips; a dropped host receiver yields a JSON-RPC error to the agent, never a hang or panic.
6. Paramount-goal alignment
- #1 perf: protocol parse/serialize runs off the UI thread; the only main-loop cost is the bounded per-tick
ClaudeCodeInbounddrain dispatched through the tick-callback registry. No per-frame protocol work. - #2 extensibility: a new peer-protocol axis (alongside LSP), mode-owned; capability-gated by loopback bind + token. Revisit a capability token when the WASM host lands so a plugin could mediate agent access.
- #3 strict vim: the IDE buffer keeps vim grammar in Normal mode (it's a terminal buffer);
claude-code-modecontributes only a minor-mode layer. - #4 async: server is a tokio task; reads use the snapshot; writes use the per-tick drain; the one blocking op blocks only the agent's task. Architectural, not by discipline.
- UX (higher court): agent edits land as the same interactive diff the user already drives (Keep/Reject) — no surprise mutations, no flicker; the IDE buffer feels like the terminal the user already knows.
7. Rejected alternatives
- New
BufferKind::Ide— would add renderer kind-arms + registry plumbing and invite kind-branching; reusing Terminal + a minor mode is lower-touch and rule-honoring. - Native embedded agent — wants the unbuilt WASM host as a sandbox, owns a far larger surface (auth, model API, tool loop), couples lattice to one agent. Peer-protocol host reuses an external agent and ships sooner. Revisit post-Phase-7.
- claude-code-specific host drain / event→Invocation writes — see §2.
See the slice plan for sequencing: docs/dev/operations/slice-plans/archive/ide-protocol.md.