第14章 Daemon + Client 架构
2026.07.29单进程 TUI 对早期原型够用——但当 agent 需要常驻运行时,daemon-client 分离是必然的演进方向。
模式层
14.1 为什么需要进程分离
单进程 TUI 架构的工作方式很简单:启动 agent → 显示终端界面 → 用户输入 → agent 处理 → 输出 → 退出。所有逻辑在同一个进程中。
这种架构的优点:简单、无需进程间通信、无需 socket、无需权限管理。对于原型和短任务场景,单进程 TUI 完全够用。
但 agent 需要常驻运行时,单进程 TUI 的局限就暴露了:
- TUI 退出意味着 agent 退出。关闭终端窗口 = 关闭 agent。Session 可能保存了,但 agent 进程终止了,常驻服务(Persistent Capability)也被终止了
- 无法多客户端连接。单进程 TUI 只能服务一个用户(终端前的用户)。如果需要多个开发者同时连接同一个 agent 实例,或者需要从 CI 脚本中触发 agent 任务,单进程 TUI 不支持
- TUI 渲染与 agent 逻辑耦合。 TUI 的渲染循环和 agent 的 turn 循环在同一个线程中——agent 执行工具时,TUI 无法更新界面
Daemon-client 分离的架构:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ Client (cc) │
└─────────────┘ │ Daemon │ └─────────────┘
│ (ccd) │
┌─────────────┐ │ │ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ CI Script │
└─────────────┘ └──────────────┘ └─────────────┘
daemon(ccd)是长驻进程,监听 Unix socket。client(cc)是短进程,连接 socket → 发送命令 → 接收事件 → 退出。daemon 不依赖 client 的存在——用户关闭终端,daemon 继续运行。
14.2 进程分离的代价
进程分离不是免费的。它引入了三个成本:
- 进程间通信延迟。每个命令和事件都需要经过 Unix socket 序列化/反序列化。虽然 Unix socket 的延迟很低(微秒级),但比函数调用高几个数量级
- 状态同步的复杂性。daemon 和 client 各自维护部分状态。daemon 维护 agent 状态、session、工具执行结果。client 维护 TUI 状态、用户输入缓冲区、显示历史。如果 daemon 崩溃,client 需要检测并通知用户
- 部署复杂性。单进程 TUI 是一个二进制文件。daemon + client 是两个二进制文件,需要管理启动顺序、socket 路径、权限。
对于大多数使用场景,这些成本是可接受的——因为 daemon 的常驻能力带来的好处远大于这些成本。
案例层
14.3 Daemon 主循环
daemon 的主循环监听 Unix socket,接受客户端连接,并为每个连接分配一个 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(())
}
daemon 主循环使用 OS 线程(不是 tokio)处理每个客户端连接。每个连接运行在自己的线程中,通过 mpsc channel 与主 agent 线程通信。
14.4 Client 入口
client 的启动流程:
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(())
}
client 的职责范围:
- 连接 daemon
- 发送用户输入(命令通道)
- 接收并显示事件流(事件通道)
- 检测 daemon 断开(socket 关闭)
- 处理 Ctrl+C 并通知 daemon
client 不维护 agent 状态——所有状态在 daemon 端。这意味着 client 可以在任何时候断开重连而不丢失 agent 的工作状态。
14.5 Wire Protocol
daemon 和 client 之间的通信协议基于 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)
}
帧的序列化格式:
[4 bytes: frame length] [1 byte: frame type] [payload: frame content]
- 帧长度:4 字节,小端序,表示 payload 的字节数
- 帧类型:1 字节,表示帧类型(Command / Event / Authenticate / Ping / Pong)
- Payload:JSON 编码的帧内容
14.6 多 Client 复用
daemon 支持多个 client 同时连接。每个 client 连接收到相同的事件流——所有 client 看到的是同一个 agent 的输出。
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);
}
event_rx 的事件通过 broadcast 分发给所有已连接的 client。这意味着多个开发者可以同时观察同一个 agent 的执行过程——这在调试 headless 模式时特别有用。
ADR 深度阅读
从单进程到 daemon-client 的演进
CodeCoder 最初是单进程 TUI——cc 二进制包含了 TUI 渲染和 agent 逻辑。进程退出 = agent 退出。
当 headless 模式需求出现时,架构师面临一个选择:在现有的单进程中添加 headless 模式(通过环境变量切换),还是分离为 daemon + client。
选择后者的原因是:headless 模式和交互式模式不仅仅是"是否显示 TUI"的区别——它们有不同的生命周期、不同的权限模型、不同的退出方式。在同一个进程中管理两种模式,要么通过条件分支让代码变得复杂,要么两个模式共享同一套状态管理——但 headless 和交互式的状态管理策略不同(交互式依赖用户确认,headless 依赖预授权)。
最终决策:将 agent 内核提取为 daemon 进程,TUI 和 headless 都作为 client 模式。daemon 不关心 client 是 TUI 还是 CI 脚本——它只通过 socket 接收命令、发送事件。这个决策显著简化了 headless 模式的实现:headless 就是一个不启动 TUI 的 client,连接 daemon 发送命令后等待事件完成。
下一章,如何观察一个自主 agent 的运行——事件流、实时日志、可观测性体系。