Skip to content

Epic: separate the REPL from the engine — typed event contract, sink inversion, TUI as first client #422

Description

@justrach

Foundation work: make the engine a library that emits typed events and accepts typed commands, and make every frontend (interactive TUI, --json, serve, ACP, GUI sidecar, iOS cube) a client of that one contract. Prerequisite for #420 (local daemon / attach); the structural fix for TUI/GUI divergence.

Where we are today

The contract half-exists: mainloop.zig is the shared "root interactive/JSON event loop", events are seq-stamped (protocol_seq.zig), and serve builds a durable EventLog with gap-free ?from=N replay on <exe> --json children (serve.zig, serve_events.zig). But the interactive TUI does not consume that stream — it is a second render path fused into the loop, and engine code writes to the terminal directly: agent_stream.zig, exec.zig, tools.zig, agent_tools.zig, agent_ws.zig, session_run.zig, edit_verify.zig, agent_tool_gate.zig, agent_prompt.zig, agent_interrupt.zig, hooks.zig, skill_docs.zig, skills.zig, imagegen.zig, review.zig, session_start.zig all import the terminal cluster. Input flows backwards too: readline.zig and pickers.zig import agent.zig to reach into session state.

Settled design decisions

  1. New internal event vocabulary. A fresh typed contract (engine_events.zig: one tagged union of every engine-to-frontend event; engine_commands.zig for the reverse direction), designed for the contract rather than inherited from the current --json shapes. The existing JSON wire becomes a serializer of it. Constraint: there is exactly ONE translation point (internal type -> wire); any externally visible shape change is gated behind a schema_version bump, and GUI/TUI/serve consumers update in lockstep (TUI-first rule).
  2. Strict sinks + explicit queries. Sinks render from events only — no side-channel reads into Agent. Anything a frontend legitimately needs on demand (model catalog for completions, session metadata, pricing state) becomes an explicit query command with a typed reply. Frontends never import agent.zig.
  3. Serialize once, route opaque. An event is serialized once at the engine boundary; routers (serve, future supervisor) forward the same bytes and parse only a small routing header. No re-serialization on any hop.
  4. Cursors are {generation, sequence} from day one. generation increments whenever a session's event log restarts (process restart, resume); sequence is monotonic within a generation. A bare sequence is meaningless across restarts; this is what makes Phase 2 attach unambiguous. Serve's existing bare-seq wire gains generation additively.

Phases

Phase 1a — output inversion (this epic's core). Introduce EngineSink (vtable). Every direct terminal write in engine files becomes a typed event emission. Two sinks: JsonSink (adapts the new vocabulary to the existing wire) and TuiSink (today's rendering relocated behind the interface — agent_render.zig, anim.zig, cards.zig, agent_table.zig become its internals). Work list = the engine-side offenders above losing their term/agent_render/ansi imports, file by file (move+alias pattern from the main.zig split, #123). This also relieves 600-line cap pressure in exactly the files pinned at it.

Phase 1b — input inversion. Approvals, pickers, and mid-turn asks become request events answered by a respond command; Esc/interrupt becomes frontend-owned stdin polling issuing an interrupt command (the engine exposes an interrupt-check seam; agent_interrupt.zig splits). Readline completions move to query commands.

Phase 2 — transport swap (#420). With every interaction expressed as events+commands, attaching the TUI to a detached worker is a sink transport change; serve's EventLog/Follower/replay is the attach machinery. Out of scope here; tracked in #420.

Enforcement

CI import-graph ratchet: engine files may not import term.zig/agent_render.zig/readline.zig/anim.zig/ansi.zig/cards.zig/pickers.zig/approvals.zig. Start at the current offender count and ratchet to zero, same mechanism as the line cap. New engine tests drive the contract directly (events in/commands out), shrinking what the PTY E2E suite has to cover to actual rendering.

Interactions

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions