Skip to content

Extract a headless engine layer: CLI/-p, REPL/TUI, --json, serve, GUI and iOS as adapters over one command core #374

Description

@justrach

graff has six-plus frontends today — interactive REPL/TUI (mainloop + zigzag graff repl), one-shot -p, the --json protocol, graff serve (embedder), the Tauri GUI, and iOS — but no formal engine boundary. The core rule this issue proposes:

Build one headless engine, preserve the CLI as the behavioral contract, use the REPL to exercise it interactively, and build the TUI/GUI as stateful clients rather than reimplementations.

Observed symptoms of the missing boundary (all from live sessions this week)

  • Core prints to a frontend it doesn't know: say() calls inside turn/tool logic render in the REPL but silently vanish in -p (the auto-promote notice is invisible in one-shot runs; only the trace note proves it fired). Structured results decided per-adapter would make every surface consistent.
  • The GUI reimplements the runtime: gui/src-tauri/src/runtime/simple.rs carries thousands of lines of parallel model/effort/provider logic that drifts from the Zig core (its own reasoning_effort plumbing, its own model settings state). Exactly the "TUI becomes the place where behavior is reimplemented" failure the doctrine warns about; iOS repeats the pattern.
  • Frontend-lifecycle correctness leaks into core paths: the pty CI failures (CI: test-pty-parallel-cancel.py times out on ubuntu runners (REPL exit=-15) — red on main since at least v0.0.233 #364) were terminal-lifecycle timing (shutdown drains under a raw pty), debugged through the whole stack because there is no seam to test the loop headlessly.
  • External readers have no protocol: the serve test tearing on in-place session-file reads (fixed reader-side) shows clients reaching into engine-owned files instead of an API.

What exists already (this is an extraction, not a rewrite)

--json protocol mode and graff serve are ALREADY adapter-shaped: structured events over a stream, seq ids, resumable sessions (schema 0.10). The Agent turn loop + tool dispatch is the de-facto command core. The work is drawing the line and enforcing it:

  1. Engine layer: the turn loop returns/emits structured events only (it largely does for --json); every say()/direct print inside core paths moves behind an event the adapter renders. Acceptance: -p, REPL, and --json show the same set of facts for the same run.
  2. Command service: one Command -> CommandResult union shared by REPL slash-commands, --json control requests, and serve endpoints (today these are three parallel dispatchers: mainloop handleCommand, protocol handlers, serve routes).
  3. Frontends as clients: REPL/TUI renders events; GUI and iOS migrate from reimplementation to the serve/--json protocol (the doctrine's CLI/REPL/TUI/MCP -> local protocol -> persistent engine diagram — serve IS that protocol).
  4. CLI as the behavioral contract: the existing script suite (test-review-mode, test-serve-resume, pty tests) becomes the adapter-conformance suite; add a golden-events test asserting -p/--json/serve parity per run.

Staged and incremental: each say()-to-event conversion and each dispatcher unification stands alone. The payoff compounds — every frontend bug this week would have been either impossible or testable headlessly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions