8. A UI-agnostic conversational driver; the bus is the only observation seam
Copy Markdown View SourceDate: 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
SessionGenServer 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:idmints a new Session; an:idresumes it, centralizing the resume robustness previously inlined inCLI.resume(theLog.exists?guard and corrupt-log-fold → structured error, not aMatchError). Re-starting an already-running Session idempotently returns the live one (the supervisor's:already_startedpath), 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
Eventsbus, 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 offersawait/2: consume until a terminalstatus, with an optionalon_eventcallback (mirroringProvider.stream's:on_delta), returning:done | :error | :interrupted | :timeout. - Permissions stay injectable. The driver implements no prompting; it passes the
askerfunction through toTurn.rununchanged (defaulting to the permission-mode behavior, so:autoworks 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.PubSubbackend swap (already anticipated inevents.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_donetreated onlydone/erroras terminal, so aninterruptedturn hung until idle-timeout.await(and the refactored Renderer path) treatinterruptedas 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.