13 KiB
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):
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):
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.ktapps/server/src/main/kotlin/.../protocol/ClientMessage.ktapps/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
Pingevery 30s, client must respond within 10s or connection dropped
files:
apps/server/src/main/kotlin/.../Application.ktapps/server/src/main/kotlin/.../routes/SessionRoutes.ktapps/server/src/main/kotlin/.../routes/WorkflowRoutes.ktapps/server/src/main/kotlin/.../routes/ProviderRoutes.ktapps/server/src/main/kotlin/.../ws/SessionStreamHandler.ktapps/server/src/main/kotlin/.../ws/GlobalStreamHandler.ktapps/server/src/main/kotlin/.../ServerModule.kt— wires InfrastructureModule + Ktor
acceptance criteria:
/healthreturns 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 →
ProtocolErrorsent, connection kept open
task 3: apps/server — approval interaction
dependencies: task 2
approval flow:
- orchestrator emits
ApprovalRequiredEvent→OrchestrationPausedEvent - server catches pause, sends
ApprovalRequiredmessage to all connected clients for that session - client sends
ApprovalResponse - server calls
approvalEngine.grant()orapprovalEngine.deny()accordingly - 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.ktapps/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 sessionc— cancel selected sessiona/r/s— approve/reject/steer (only active when approval pending)↑↓— navigate session listenter— select/expand sessionq— 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 pointapps/tui/src/main/kotlin/.../components/StatusBar.ktapps/tui/src/main/kotlin/.../components/SessionList.ktapps/tui/src/main/kotlin/.../components/ActiveSession.ktapps/tui/src/main/kotlin/.../components/ApprovalPrompt.ktapps/tui/src/main/kotlin/.../components/InputBar.ktapps/tui/src/main/kotlin/.../ws/TuiWsClient.kt— websocket clientapps/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
ApprovalRequiredmessage - approve/reject/steer sends correct
ApprovalResponseto 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 <path> [--session <id>] [--auto-approve]
correx session list [--limit 20]
correx session resume <id>
correx session cancel <id>
correx session events <id> [--from <eventId>]
correx approve <session-id> --decision <approve|reject|steer> [--note "..."]
correx status
correx provider list
correx config [--edit]
output format:
- default: human-readable, colored
--jsonflag: machine-readable JSON on stdout for all commands--quietflag: 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-approvenot 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 commandapps/cli/src/main/kotlin/.../commands/RunCommand.ktapps/cli/src/main/kotlin/.../commands/SessionCommand.ktapps/cli/src/main/kotlin/.../commands/ApproveCommand.ktapps/cli/src/main/kotlin/.../commands/StatusCommand.ktapps/cli/src/main/kotlin/.../commands/ProviderCommand.ktapps/cli/src/main/kotlin/.../ws/CliWsClient.kt
acceptance criteria:
correx runexits with correct codes--jsonproduces valid parseable JSON for all commands- approval prompt works in TTY, auto-denies without TTY when flag absent
correx session listoutput matches server/sessionsresponse
task 6: configuration
shared config file: ~/.config/correx/config.toml (or $CORREX_CONFIG)
[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.ktapps/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)