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
- 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).
- 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.
- 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.
- 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
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.zigis the shared "root interactive/JSON event loop", events are seq-stamped (protocol_seq.zig), and serve builds a durable EventLog with gap-free?from=Nreplay on<exe> --jsonchildren (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.zigall import the terminal cluster. Input flows backwards too:readline.zigandpickers.zigimportagent.zigto reach into session state.Settled design decisions
engine_events.zig: one tagged union of every engine-to-frontend event;engine_commands.zigfor the reverse direction), designed for the contract rather than inherited from the current--jsonshapes. 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 aschema_versionbump, and GUI/TUI/serve consumers update in lockstep (TUI-first rule).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 importagent.zig.{generation, sequence}from day one.generationincrements whenever a session's event log restarts (process restart, resume);sequenceis 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 gainsgenerationadditively.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) andTuiSink(today's rendering relocated behind the interface —agent_render.zig,anim.zig,cards.zig,agent_table.zigbecome its internals). Work list = the engine-side offenders above losing theirterm/agent_render/ansiimports, 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
respondcommand; Esc/interrupt becomes frontend-owned stdin polling issuing aninterruptcommand (the engine exposes an interrupt-check seam;agent_interrupt.zigsplits). 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
session_recapevent should be born in the new vocabulary, not the old wire.