8. A UI-agnostic conversational driver; the bus is the only observation seam

Copy Markdown View Source

Date: 2026-05-29 Status: Accepted

Context

The v0.1 CLI runs exactly one Turn per process (one_shot/resume start a Session, run a Turn, print the resume id, halt). The multi-turn loop — accept input → run a Turn → stream events → accept the next input while keeping the Session alive — does not exist anywhere; the CLI inlines a single non-looping iteration of it.

That loop is needed identically by every front-end. The target front-end here is not the terminal: the user drives Pixir from a non-Elixir UI (an HTTP/WebSocket client, eventually). ADR 0004 already established that "the event bus is the seam between the core and every front-end" and that front-ends are thin subscribers; ADR 0001 makes the Session the single stateful unit of agency. So the missing piece is a small, UI-agnostic driver over that core — and the terminal REPL the ROADMAP listed is just one optional presenter of it, which this user will skip.

Decision

Add Pixir.Conversation: a stateless functional module over the existing Session/Turn/Events API. It owns orchestration, not state.

  • Not a process. The Session GenServer already owns turn state, history, seq, and interrupt. A second process would duplicate ownership and re-introduce the two-store hazard ADR 0004 was written to avoid. Any per-client state (a socket, a pending permission reply) belongs to the transport tier, not the driver.
  • start(opts) — no :id mints a new Session; an :id resumes it, centralizing the resume robustness previously inlined in CLI.resume (the Log.exists? guard and corrupt-log-fold → structured error, not a MatchError). Re-starting an already-running Session idempotently returns the live one (the supervisor's :already_started path), so a reconnecting client can reattach.
  • send(session_id, prompt, opts) — the generalized one-turn driver: start_turn(sid, fn ctx -> Turn.run(ctx, prompt, …) end). Non-blocking; the caller observes via the bus.
  • Observation is the Events bus, full stop. The driver invents no new streaming abstraction. An out-of-process UI's transport tier subscribes (Events.subscribe(session_id)) and forwards each {:pixir_event, event} over its socket as JSON (events are already string-keyed/JSON-shaped, ADR 0004). For in-process callers (tests, an optional terminal presenter) the driver offers await/2: consume until a terminal status, with an optional on_event callback (mirroring Provider.stream's :on_delta), returning :done | :error | :interrupted | :timeout.
  • Permissions stay injectable. The driver implements no prompting; it passes the asker function through to Turn.run unchanged (defaulting to the permission-mode behavior, so :auto works immediately). Async, remote permission decisions are a transport-tier concern: that layer supplies an asker closure that blocks the Turn task while it round-trips the decision over its socket. Deferred deliberately, not silently.

The CLI is refactored onto the driver (its front-end logic becomes a thin await + terminal renderer), which both proves the seam and deletes the duplicated turn/resume logic.

Consequences

  • One multi-turn surface reused by every front-end — terminal, HTTP/WS, editor, or an embedding Elixir app. The HTTP/WS tier (a later step) adds: a transport endpoint, Log-backed cursor backfill for reconnects, the Phoenix.PubSub backend swap (already anticipated in events.ex), the async permission path, and auth/multi-session management — none of which the driver itself needs to know about.
  • Fixes a latent bug: the Renderer's consume_until_done treated only done/error as terminal, so an interrupted turn hung until idle-timeout. await (and the refactored Renderer path) treat interrupted as terminal.
  • The terminal REPL is now optional — it would be await + a render callback in a read-input loop. This user can skip it entirely.
  • Cost: the driver is a deliberately thin layer; the temptation to grow it into a stateful session manager must be resisted — that state is the transport tier's.