Status: Implemented - Phases 1, 2, and 3 completed.
-
Local TUI Mode (Direct Channels)
- Runner sends
AppUpdateviampsc::UnboundedSender<AppUpdate> - TUI receives updates through
mpsc::UnboundedReceiver<AppUpdate> - Updates are immediate but single-client only
- Runner sends
-
Remote TUI Mode (HTTP/SSE)
- Server has an event bus (
wonopcode_core::Bus) usingtokio::sync::broadcast - SSE endpoint (
/events) subscribes tobus.subscribe_all()and streams events - TUI's
RemoteBackendconnects to SSE and parses events intoAppUpdate
- Server has an event bus (
-
Todo Update Flow
todowritetool stores todos inSharedFileTodoStore(temp file)- After tool execution completes, runner reads todos and sends
AppUpdate::TodosUpdated - This happens at
runner.rs:2661-2684inside the tool execution future
The TODO sync happens after each tool completes within the parallel execution:
// Inside tool future at runner.rs:2661-2684
if base_tool_name == "todowrite" && success {
let todos = todo::get_todos(todo_store.as_ref(), &cwd);
// ... convert to TodoUpdate ...
let _ = update_tx.send(AppUpdate::TodosUpdated(todo_updates));
}Issue: The update is sent from inside an async task. If the channel is backed up or the TUI is busy rendering, the update may be delayed. Also, update_tx.send() uses let _ = which silently ignores errors.
When a client connects after the LLM has started:
- They subscribe to SSE and only receive future events
- Current state (todos, modified files, active tools) is lost
- The
/stateendpoint exists but doesn't include runtime state like todos
broadcast::channelcan lag - if receiver is slow, events are dropped- SSE already logs this:
"SSE stream lagged by {} events"(sse.rs:24) - No replay mechanism for missed events
- One-way communication limits client control
- Local mode:
SharedFileTodoStore(temp file shared via env var) - Server mode:
SharedTodoStore(Arc<RwLock<Vec<TodoItem>>>) - These don't sync - server's
session_todoroute has fallback logic
Goal: Make TODO updates appear immediately without waiting for tool completion.
Changes:
- Publish
TodoUpdatedevent on the bus immediately whentodowriteexecutes - Have the TUI subscribe to this bus event
Implementation:
// In TodoWriteTool::execute() after saving to store
if let Some(bus) = ctx.bus.as_ref() {
bus.publish(TodoUpdated {
session_id: ctx.session_id.clone(),
items: items.iter().map(|t| /* convert */).collect(),
}).await;
}Pros: Simple, uses existing infrastructure
Cons: Requires passing Bus through ToolContext, doesn't solve multi-client state recovery
Goal: Support late-joining clients with full state recovery and reliable event delivery.
Create a comprehensive /api/v1/state endpoint that returns ALL runtime state:
#[derive(Serialize)]
struct FullState {
// Session info
session_id: String,
status: SessionStatus,
// Runtime state
todos: Vec<TodoInfo>,
modified_files: Vec<ModifiedFileInfo>,
active_tools: Vec<ActiveToolInfo>,
// Connection state
lsp_servers: Vec<LspInfo>,
mcp_servers: Vec<McpInfo>,
sandbox: Option<SandboxStatus>,
// Token usage
token_usage: Option<TokenUsage>,
// Event sequence number (for syncing)
last_event_seq: u64,
}Add sequence numbers to events for reliable delivery:
#[derive(Clone, Serialize)]
struct SequencedEvent {
seq: u64,
timestamp: i64,
event: BusEvent,
}Store recent events in a ring buffer (e.g., last 1000 events) so clients can request replay.
GET /api/v1/events/replay?from_seq={seq}&limit={limit}
Returns events from the given sequence number, allowing clients to catch up.
Goal: Bidirectional communication with better connection management.
// Client -> Server
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ClientMessage {
Subscribe { events: Vec<String> },
Unsubscribe { events: Vec<String> },
RequestState,
Ping,
}
// Server -> Client
#[derive(Serialize)]
#[serde(tag = "type")]
enum ServerMessage {
Event { seq: u64, event: BusEvent },
State { state: FullState },
Pong,
Error { message: String },
}- Client connects to
ws://server/api/v1/ws - Server sends initial
Statemessage with full state +last_event_seq - Server streams
Eventmessages as they occur - Client can request state refresh anytime
- Heartbeat with
Ping/Pongfor connection health
use axum::extract::ws::{WebSocket, WebSocketUpgrade};
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: AppState) {
// 1. Send initial state
let full_state = build_full_state(&state).await;
let _ = socket.send(ServerMessage::State { state: full_state }.to_ws_message()).await;
// 2. Subscribe to events
let mut event_rx = state.bus.subscribe_all();
// 3. Event loop
loop {
tokio::select! {
// Handle incoming messages
Some(msg) = socket.recv() => {
match parse_client_message(msg) {
ClientMessage::RequestState => {
let state = build_full_state(&state).await;
let _ = socket.send(ServerMessage::State { state }.to_ws_message()).await;
}
ClientMessage::Ping => {
let _ = socket.send(ServerMessage::Pong.to_ws_message()).await;
}
// ...
}
}
// Forward events
Ok(event) = event_rx.recv() => {
let _ = socket.send(ServerMessage::Event { seq, event }.to_ws_message()).await;
}
}
}
}-
Fix silent channel errors
- Change
let _ = update_tx.send(...)to proper error logging - Location:
runner.rslines 2652, 2683, etc.
- Change
-
Add Bus to ToolContext
- Pass
BusthroughToolContext - Publish
TodoUpdatedevent immediately inTodoWriteTool::execute()
- Pass
-
Unify todo storage
- Use single
SharedTodoStorein server mode - Remove file-based fallback complexity
- Use single
-
Enhance
/stateendpoint- Include todos, modified files, active tools
- Add
last_event_seqfor sync coordination
-
Add event sequence numbers
- Modify
Busto track sequence - Store recent events for replay
- Modify
-
Create
/events/replayendpoint- Allow clients to catch up on missed events
-
Add axum WebSocket support
# wonopcode-server/Cargo.toml axum = { version = "0.7", features = ["ws"] }
-
Implement WebSocket handler
- Initial state on connect
- Event streaming
- Client message handling
-
Update TUI backend
- Add
WebSocketBackendoption - Prefer WebSocket when available, fallback to SSE
- Add
-
Event filtering
- Allow clients to subscribe to specific event types
- Reduce bandwidth for clients that don't need all events
-
Presence tracking
- Know which clients are connected
- Broadcast presence to other clients
-
Collaborative editing support
- Multiple clients can see same session
- Conflict resolution for actions
crates/wonopcode/src/runner.rs- Better error handling, bus publishingcrates/wonopcode-tools/src/lib.rs- AddBustoToolContextcrates/wonopcode-tools/src/todo.rs- Publish events on write
crates/wonopcode-core/src/bus.rs- Add sequence numbers, event storagecrates/wonopcode-server/src/routes.rs- Enhanced state, replay endpointscrates/wonopcode-server/src/state.rs- Runtime state tracking
crates/wonopcode-server/src/ws.rs- New WebSocket modulecrates/wonopcode-server/src/lib.rs- Export ws modulecrates/wonopcode-server/src/routes.rs- Add ws routecrates/wonopcode-tui/src/backend.rs- Add WebSocketBackendcrates/wonopcode-protocol/src/lib.rs- WebSocket message types
-
Unit tests
- Event sequencing
- State snapshot accuracy
- WebSocket message parsing
-
Integration tests
- Client connects mid-session, receives full state
- Multiple clients receive same events
- Event replay after reconnection
-
Manual testing
- Start LLM task, connect second TUI
- Kill connection, reconnect, verify state
- TODO updates appear immediately
- SSE endpoint remains for backwards compatibility
- WebSocket is additive, not replacing SSE
- Phase 1 changes are non-breaking
- Protocol additions are backwards compatible (new optional fields)