法不净空,觉无性也。

第14章 Daemon + Client 架构

2026.07.29

单进程 TUI 对早期原型够用——但当 agent 需要常驻运行时,daemon-client 分离是必然的演进方向。


模式层

14.1 为什么需要进程分离

单进程 TUI 架构的工作方式很简单:启动 agent → 显示终端界面 → 用户输入 → agent 处理 → 输出 → 退出。所有逻辑在同一个进程中。

这种架构的优点:简单、无需进程间通信、无需 socket、无需权限管理。对于原型和短任务场景,单进程 TUI 完全够用。

但 agent 需要常驻运行时,单进程 TUI 的局限就暴露了:

  1. TUI 退出意味着 agent 退出。关闭终端窗口 = 关闭 agent。Session 可能保存了,但 agent 进程终止了,常驻服务(Persistent Capability)也被终止了
  2. 无法多客户端连接。单进程 TUI 只能服务一个用户(终端前的用户)。如果需要多个开发者同时连接同一个 agent 实例,或者需要从 CI 脚本中触发 agent 任务,单进程 TUI 不支持
  3. 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 进程分离的代价

进程分离不是免费的。它引入了三个成本:

  1. 进程间通信延迟。每个命令和事件都需要经过 Unix socket 序列化/反序列化。虽然 Unix socket 的延迟很低(微秒级),但比函数调用高几个数量级
  2. 状态同步的复杂性。daemon 和 client 各自维护部分状态。daemon 维护 agent 状态、session、工具执行结果。client 维护 TUI 状态、用户输入缓冲区、显示历史。如果 daemon 崩溃,client 需要检测并通知用户
  3. 部署复杂性。单进程 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 的运行——事件流、实时日志、可观测性体系。