













单进程 TUI 对早期原型够用——但当 agent 需要常驻运行时,daemon-client 分离是必然的演进方向。
单进程 TUI 架构的工作方式很简单:启动 agent → 显示终端界面 → 用户输入 → agent 处理 → 输出 → 退出。所有逻辑在同一个进程中。
这种架构的优点:简单、无需进程间通信、无需 socket、无需权限管理。对于原型和短任务场景,单进程 TUI 完全够用。
但 agent 需要常驻运行时,单进程 TUI 的局限就暴露了:
Daemon-client 分离的架构:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ Client (cc) │
└─────────────┘ │ Daemon │ └─────────────┘
│ (ccd) │
┌─────────────┐ │ │ ┌─────────────┐
│ Client (cc) │────▶│ │◀────│ CI Script │
└─────────────┘ └──────────────┘ └─────────────┘
daemon(ccd)是长驻进程,监听 Unix socket。client(cc)是短进程,连接 socket → 发送命令 → 接收事件 → 退出。daemon 不依赖 client 的存在——用户关闭终端,daemon 继续运行。
进程分离不是免费的。它引入了三个成本:
对于大多数使用场景,这些成本是可接受的——因为 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 线程通信。
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 的职责范围:
client 不维护 agent 状态——所有状态在 daemon 端。这意味着 client 可以在任何时候断开重连而不丢失 agent 的工作状态。
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]
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 模式时特别有用。
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 的运行——事件流、实时日志、可观测性体系。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。