Chapter 14: Daemon + Client Architecture
2026.08.10A single-process TUI suffices for early prototypes—but when an agent needs to run persistently, separating daemon from client becomes an inevitable evolution.
Pattern Layer
14.1 Why Process Separation
The single-process TUI architecture works simply: start agent → display terminal interface → user input → agent processes → output → exit. All logic lives in one process.
This architecture's advantage is simplicity: no inter-process communication, no sockets, no permission management. For prototypes and short-lived tasks, a single-process TUI is perfectly adequate.
But when an agent needs to run persistently, the limitations of a single-process TUI become apparent:
- TUI exit means agent exit. Closing the terminal window = shutting down the agent. The session may be saved, but the agent process terminates, and any persistent capabilities terminate with it.
- No multi-client connectivity. A single-process TUI can only serve one user (the person at the terminal). If multiple developers need to connect to the same agent instance simultaneously, or if agent tasks need to be triggered from CI scripts, the single-process TUI does not support it.
- TUI rendering coupled with agent logic. The TUI render loop and the agent turn loop share the same thread—when the agent executes a tool, the TUI cannot update its display.
The daemon-client separation architecture:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ Client (cc) │
└─────────────┘ │ Daemon │ └─────────────┘
│ (ccd) │
┌─────────────┐ │ │ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ CI Script │
└─────────────┘ └──────────────┘ └─────────────┘
The daemon (ccd) is a long-running process that listens on a Unix socket. The client (cc) is a short-lived process that connects to the socket → sends a command → receives events → exits. The daemon does not depend on the client's existence—if the user closes the terminal, the daemon continues running.
14.2 The Cost of Process Separation
Process separation is not free. It introduces three costs:
- Inter-process communication latency. Every command and event must be serialized/deserialized over a Unix socket. Although Unix socket latency is low (microsecond scale), it is orders of magnitude higher than a function call.
- State synchronization complexity. The daemon and client each maintain partial state. The daemon holds agent state, sessions, and tool execution results. The client holds TUI state, user input buffers, and display history. If the daemon crashes, the client must detect this and notify the user.
- Deployment complexity. A single-process TUI is a single binary. Daemon + client is two binaries, requiring management of startup order, socket path, and permissions.
For most use cases, these costs are acceptable—because the benefits of persistent daemon operation far outweigh them.
Case Layer
14.3 Daemon Main Loop
The daemon's main loop listens on a Unix socket, accepts client connections, and assigns each a ClientHandler:
fn daemon_main() -> Result<()> {
let socket_path = config.socket_path();
// Clean up old socket file
let _ = fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path)?;
// Set permissions: only allow the current user to connect
set_permissions(&socket_path, 0o700)?;
// daemon main loop
for stream in listener.incoming() {
let stream = stream?;
// Each client connection is handled in a new thread
thread::spawn(move || {
handle_client(stream);
});
}
Ok(())
}
The daemon main loop uses OS threads (not tokio) to handle each client connection. Each connection runs in its own thread, communicating with the main agent thread via an mpsc channel.
14.4 Client Entry Point
The client startup flow:
fn client_main() -> Result<()> {
// 1. Connect to daemon
let socket = connect_to_daemon()?;
// 2. Send authentication
authenticate(&socket)?;
// 3. Send command
let command = parse_command()?;
send_command(&socket, command)?;
// 4. Event receive loop
loop {
let event = receive_event(&socket)?;
match event {
Event::NewToken(token) => print!("{}", token),
Event::ToolStarted { .. } => eprintln!("\n[Tool executing]"),
Event::ToolFinished { result, .. } => {
eprintln!("\n[Tool completed]");
if result.is_err() {
eprintln!("Error: {}", result.unwrap_err());
}
}
Event::Done { .. } => break,
Event::Error(msg) => {
eprintln!("Error: {}", msg);
break;
}
}
}
Ok(())
}
The client's responsibilities are:
- Connect to the daemon
- Send user input (command channel)
- Receive and display the event stream (event channel)
- Detect daemon disconnection (socket close)
- Handle Ctrl+C and notify the daemon
The client does not maintain agent state—all state resides on the daemon side. This means the client can disconnect and reconnect at any time without losing the agent's working state.
14.5 Wire Protocol
The communication protocol between daemon and client is a frame-based protocol over Unix socket:
enum Frame {
Command(AgentCommand), // client → daemon
Event(AgentEvent), // daemon → client
Authenticate { token: String }, // client → daemon
Pong, // daemon → client (heartbeat response)
Ping, // client → daemon (heartbeat request)
}
The frame serialization format:
[4 bytes: frame length] [1 byte: frame type] [payload: frame content]
- Frame length: 4 bytes, little-endian, indicates the byte count of the payload
- Frame type: 1 byte, indicates the frame type (Command / Event / Authenticate / Ping / Pong)
- Payload: JSON-encoded frame content
14.6 Multi-Client Multiplexing
The daemon supports multiple clients connected simultaneously. Each client connection receives the same event stream—all clients see the same agent output.
fn handle_client(stream: UnixStream) {
let (reader, writer) = stream.split();
// Register writer in the broadcast list
let client_id = broadcast.register(writer);
// Read loop
for frame in FrameReader::new(reader) {
match frame {
Frame::Command(cmd) => {
// Command is sent to the agent thread via cmd_tx
cmd_tx.send(cmd);
}
Frame::Ping => {
// Reply with Pong
writer.send(Frame::Pong);
}
Frame::Authenticate { token } => {
// Verify token
if authenticate(&token) {
writer.send(Frame::Event(AgentEvent::StatusUpdate(AgentStatus::Running)));
} else {
writer.send(Frame::Event(AgentEvent::Error("Authentication failed".into())));
break;
}
}
_ => {} // Ignore other frame types
}
}
// Client disconnected
broadcast.unregister(client_id);
}
Events from event_rx are distributed to all connected clients via the broadcast mechanism. This means multiple developers can observe the same agent execution simultaneously—particularly useful when debugging headless mode.
ADR Deep Dive
Evolution from Single-Process to Daemon-Client
CodeCoder initially started as a single-process TUI—the cc binary contained both TUI rendering and agent logic. Process exit = agent exit.
When the need for headless mode emerged, the architects faced a choice: add headless mode to the existing single process (toggled via environment variable), or split into daemon + client.
The latter was chosen because headless mode and interactive mode differ in more than just "whether to display the TUI"—they have different lifecycles, different permission models, and different exit behaviors. Managing both modes in the same process would either complicate the code with conditional branches, or force both modes to share the same state management strategy—but headless and interactive modes have different state management strategies (interactive relies on user confirmation, headless relies on pre-authorization).
The final decision: extract the agent kernel into a daemon process, with both TUI and headless operating as client modes. The daemon does not care whether the client is a TUI or a CI script—it only receives commands and sends events over the socket. This decision significantly simplified the headless mode implementation: headless is simply a client that does not start a TUI, connects to the daemon, sends a command, and waits for events to complete.
Next chapter: how to observe a running autonomous agent—event streams, real-time logs, and the observability system.