# Epic 13 — Interfaces (TUI + CLI + API) **status:** planned **depends on:** epics 0–12 **goal:** user interaction layer — server owns the orchestrator, TUI and CLI are clients --- ## architecture ``` apps/server ← owns orchestrator, event store, all infrastructure. Ktor. apps/tui ← Mosaic-based interactive terminal. websocket client only. apps/cli ← Clikt-based non-interactive entrypoint. http/websocket client. ``` all three are clients of `core:kernel` + `infrastructure` through `apps/server`. TUI and CLI never instantiate orchestrator directly — they talk to the server. --- ## task 1: websocket protocol definition **this must be done first.** everything else depends on it. define the protocol as a sealed class hierarchy, serialized via kotlinx.serialization: ### server → client (push): ```kotlin sealed class ServerMessage { // session lifecycle data class SessionStarted(val sessionId: SessionId, val workflowId: String) : ServerMessage() data class SessionPaused(val sessionId: SessionId, val reason: PauseReason) : ServerMessage() data class SessionCompleted(val sessionId: SessionId) : ServerMessage() data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage() // stage execution data class StageStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage() data class StageCompleted(val sessionId: SessionId, val stageId: StageId) : ServerMessage() data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String) : ServerMessage() // inference data class InferenceStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage() data class InferenceCompleted(val sessionId: SessionId, val stageId: StageId, val outputSummary: String) : ServerMessage() data class InferenceTimedOut(val sessionId: SessionId, val stageId: StageId, val elapsedMs: Long) : ServerMessage() // tool execution data class ToolStarted(val sessionId: SessionId, val toolName: String, val tier: Tier) : ServerMessage() data class ToolCompleted(val sessionId: SessionId, val toolName: String, val outputSummary: String) : ServerMessage() data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String) : ServerMessage() data class ToolRejected(val sessionId: SessionId, val toolName: String, val reason: String) : ServerMessage() // approval data class ApprovalRequired( val sessionId: SessionId, val requestId: ApprovalRequestId, val tier: Tier, val riskSummary: RiskSummaryDto, val toolName: String?, val preview: String?, ) : ServerMessage() // provider data class ProviderStatusChanged(val providerId: String, val status: ProviderHealthDto) : ServerMessage() // error data class ProtocolError(val message: String) : ServerMessage() } ``` ### client → server (commands): ```kotlin sealed class ClientMessage { data class StartSession(val workflowId: String, val config: SessionConfigDto?) : ClientMessage() data class ResumeSession(val sessionId: SessionId) : ClientMessage() data class CancelSession(val sessionId: SessionId) : ClientMessage() data class ApprovalResponse( val requestId: ApprovalRequestId, val decision: ApprovalDecision, // APPROVE, REJECT, STEER val steeringNote: String?, ) : ClientMessage() data class Ping(val timestamp: Long) : ClientMessage() } ``` **files:** - `apps/server/src/main/kotlin/.../protocol/ServerMessage.kt` - `apps/server/src/main/kotlin/.../protocol/ClientMessage.kt` - `apps/server/src/main/kotlin/.../protocol/ProtocolSerializer.kt` — register both hierarchies **acceptance criteria:** - all messages serialize/deserialize correctly - unknown message type produces `ProtocolError`, not crash - protocol types are DTOs — no domain types leak into protocol layer --- ## task 2: apps/server — Ktor server + websocket **dependencies:** task 1 **endpoints:** ``` GET /health → server + provider status GET /sessions → list recent sessions (last 20) GET /sessions/{id} → session detail + current state POST /sessions → start new session (body: StartSessionRequest) POST /sessions/{id}/cancel → cancel session GET /sessions/{id}/events → full event log (paginated) WS /sessions/{id}/stream → live event stream for session WS /stream → global event stream (all sessions) GET /workflows → list available workflow definitions GET /providers → provider registry + health ``` **websocket behavior:** - on connect: send snapshot of current session state, then stream live events - on disconnect: clean up subscription, no state lost - on reconnect: client sends last received event id, server replays missed events from that point - heartbeat: server sends `Ping` every 30s, client must respond within 10s or connection dropped **files:** - `apps/server/src/main/kotlin/.../Application.kt` - `apps/server/src/main/kotlin/.../routes/SessionRoutes.kt` - `apps/server/src/main/kotlin/.../routes/WorkflowRoutes.kt` - `apps/server/src/main/kotlin/.../routes/ProviderRoutes.kt` - `apps/server/src/main/kotlin/.../ws/SessionStreamHandler.kt` - `apps/server/src/main/kotlin/.../ws/GlobalStreamHandler.kt` - `apps/server/src/main/kotlin/.../ServerModule.kt` — wires InfrastructureModule + Ktor **acceptance criteria:** - `/health` returns 200 with provider status - websocket stream delivers events within 100ms of emission - reconnect with last event id replays missed events correctly - unknown WS message type → `ProtocolError` sent, connection kept open --- ## task 3: apps/server — approval interaction **dependencies:** task 2 approval flow: 1. orchestrator emits `ApprovalRequiredEvent` → `OrchestrationPausedEvent` 2. server catches pause, sends `ApprovalRequired` message to all connected clients for that session 3. client sends `ApprovalResponse` 4. server calls `approvalEngine.grant()` or `approvalEngine.deny()` accordingly 5. orchestrator resumes **steering:** `ApprovalDecision.STEER` + `steeringNote` injects a steering event into the session event stream before resuming. the note becomes L0 context for the next stage. **timeout:** if no response within `ApprovalConfig.timeoutMs`, server auto-denies and emits `ApprovalTimedOutEvent`. configurable, default 5 minutes. **files:** - `apps/server/src/main/kotlin/.../approval/ApprovalCoordinator.kt` - `apps/server/src/main/kotlin/.../routes/ApprovalRoutes.kt` — REST fallback for non-WS clients **acceptance criteria:** - approval required → session pauses → client receives message - approve → session resumes - reject → session fails with reason - steer → steering note injected → session resumes with note in context - timeout → auto-deny, event emitted --- ## task 4: apps/tui — Mosaic dashboard **dependencies:** task 2, task 3 **layout:** ``` ┌─ status bar ──────────────────────────────────────────────────────┐ │ server: ● connected │ provider: gemma-4 (loaded) │ gpu: 74% │ cwd │ ├─ session list ────────────────────────────────────────────────────┤ │ ▶ [abc123] "fix auth bug" ACTIVE stage 3/5 2m ago │ │ [def456] "refactor context" PAUSED awaiting approval │ │ [ghi789] "add tool registry" DONE 5m ago │ │ [jkl012] "write tests for..." FAILED 12m ago │ │ [mno345] "epic 11 wiring" DONE 1h ago │ ├─ active session ──────────────────────────────────────────────────┤ │ stage: execute_plan (3/5) │ │ last output: "FileEditTool applied patch to src/main/..." │ │ tool: FileEditTool ████████░░ T2 │ ├─ approval prompt (when pending) ──────────────────────────────────┤ │ ⚠ APPROVAL REQUIRED — Tier T3 │ │ tool: ShellTool argv: ["rm", "-rf", "build/"] │ │ risk: HIGH — DestructiveOperation, RepeatedFailure(3/3) │ │ [A] approve [R] reject [S] steer [D] details │ ├─ input ────────────────────────────────────────────────────────────┤ │ > _ │ ├─ keybinds ────────────────────────────────────────────────────────┤ │ n new r resume c cancel a approve q quit ? help , config │ └───────────────────────────────────────────────────────────────────┘ ``` **keybinds:** - `n` — new session (prompts for workflow selection) - `r` — resume selected session - `c` — cancel selected session - `a/r/s` — approve/reject/steer (only active when approval pending) - `↑↓` — navigate session list - `enter` — select/expand session - `q` — quit - `,` — open config - `?` — help overlay **state management:** Mosaic reactive state. server events update state via websocket listener coroutine. no polling. **files:** - `apps/tui/src/main/kotlin/.../TuiApp.kt` — Mosaic entry point - `apps/tui/src/main/kotlin/.../components/StatusBar.kt` - `apps/tui/src/main/kotlin/.../components/SessionList.kt` - `apps/tui/src/main/kotlin/.../components/ActiveSession.kt` - `apps/tui/src/main/kotlin/.../components/ApprovalPrompt.kt` - `apps/tui/src/main/kotlin/.../components/InputBar.kt` - `apps/tui/src/main/kotlin/.../ws/TuiWsClient.kt` — websocket client - `apps/tui/src/main/kotlin/.../state/TuiState.kt` **acceptance criteria:** - status bar updates within 1s of provider state change - session list shows last 5 sessions with correct status - approval prompt appears immediately on `ApprovalRequired` message - approve/reject/steer sends correct `ApprovalResponse` to server - disconnect from server → status bar shows reconnecting state, no crash --- ## task 5: apps/cli — Clikt entrypoint **dependencies:** task 2, task 3 **commands:** ``` correx run --workflow [--session ] [--auto-approve] correx session list [--limit 20] correx session resume correx session cancel correx session events [--from ] correx approve --decision [--note "..."] correx status correx provider list correx config [--edit] ``` **output format:** - default: human-readable, colored - `--json` flag: machine-readable JSON on stdout for all commands - `--quiet` flag: minimal output, only errors **`correx run` behavior:** - starts session, subscribes to websocket stream - prints stage transitions and tool calls to stdout - on approval required: prompts interactively if TTY, auto-denies if `--auto-approve` not set and no TTY - exits 0 on session complete, 1 on failure, 2 on approval denied **files:** - `apps/cli/src/main/kotlin/.../CorrexCli.kt` — root Clikt command - `apps/cli/src/main/kotlin/.../commands/RunCommand.kt` - `apps/cli/src/main/kotlin/.../commands/SessionCommand.kt` - `apps/cli/src/main/kotlin/.../commands/ApproveCommand.kt` - `apps/cli/src/main/kotlin/.../commands/StatusCommand.kt` - `apps/cli/src/main/kotlin/.../commands/ProviderCommand.kt` - `apps/cli/src/main/kotlin/.../ws/CliWsClient.kt` **acceptance criteria:** - `correx run` exits with correct codes - `--json` produces valid parseable JSON for all commands - approval prompt works in TTY, auto-denies without TTY when flag absent - `correx session list` output matches server `/sessions` response --- ## task 6: configuration **shared config file:** `~/.config/correx/config.toml` (or `$CORREX_CONFIG`) ```toml [server] host = "localhost" port = 8080 [tui] theme = "dark" session_list_limit = 5 [cli] default_output = "human" [approval] timeout_ms = 300000 # 5 minutes ``` config accessible from both TUI (`,` keybind) and CLI (`correx config --edit`). **files:** - `apps/shared/src/main/kotlin/.../config/CorrexConfig.kt` - `apps/shared/src/main/kotlin/.../config/ConfigLoader.kt` --- ## sequencing ``` task 1 (protocol) ↓ task 2 (server + websocket) ↓ task 3 (approval interaction) ← depends on task 2 ↓ task 4 (TUI) task 5 (CLI) ← both depend on tasks 2+3, parallel ↓ task 6 (config) ← shared, can be done alongside 4+5 ``` --- ## deferred to epic 14+ - router layer (L2/L3 memory bridge) - workflow definition editor in TUI - session diff viewer - replay viewer (epic 15) - GPU memory pressure indicator beyond simple % usage - multi-server TUI (connecting to remote correx instances)