Skip to content

第 19 章:终端界面与客户端架构

终端 AI Agent 的界面设计面临一个根本张力:终端的字符网格天然适合"纯文本 in / 纯文本 out",但 Agent 的输出是流式 Markdown、工具调用摘要、权限请求等富结构内容——如何在 80×24 的约束下提供接近 IDE 的交互体验,同时保持终端原生的键盘驱动效率,是本章的核心问题。


19.1 TUI 设计哲学

极简与极致的共存

终端 AI Agent 的界面设计存在两个极端:

  1. 极简派——不做任何 TUI 框架,直接用 ANSI escape code 在 stdout 上打字。输入是 readline,输出是滚动文本流。
  2. 极致派——用 ratatui / Ink 等框架搭建完整 TUI,支持面板分割、弹窗、fuzzy search、语法高亮、流式 Markdown 渲染。

OpenCode 的创始人明确说过:"built by neovim users... push the limits of what's possible in the terminal"。这不是一句空话——它意味着 TUI 不是 GUI 的降级替代,而是一等公民:键盘快捷键覆盖所有操作,鼠标是可选的补充。

OpenCode 界面

极简 CLI 交互:mini-codex 的设计

并非所有场景都需要全功能 TUI。极简 CLI 的设计哲学是零依赖、零学习成本:用户输入一行文本,Agent 输出一段文本,中间穿插工具调用的审批提示。整个 UI 层不到 250 行 Rust 代码。

极简 CLI 的核心 UI 元素只有四个:

元素实现方式作用
状态栏ANSI dim 文本显示 workspace、model、token 用量
角色前缀彩色 role> 标记区分 user / assistant / tool 输出
审批提示阻塞式 stdin 读取[y]es / [n]o / [a]uto 三选一
等待动画后台线程旋转字符|/-\ 四帧 spinner,120ms 间隔

输出截断策略体现了极简设计的务实取舍:预览最多 10 行 / 1500 字符,超出部分折叠但完整保留在会话历史中。这个设计点很重要——截断只影响显示,不影响 Agent 的上下文。

rust
fn fold_lines_for_display(text: &str, max_lines: usize) -> String {
    let lines = text.lines().collect::<Vec<_>>();
    let clipped_lines = lines.iter().take(max_lines).copied()
        .collect::<Vec<_>>().join("\n");
    let mut preview = if clipped_lines.chars().count() > MAX_PREVIEW_CHARS {
        clipped_lines.chars().take(MAX_PREVIEW_CHARS).collect()
    } else { clipped_lines };
    if lines.len() > max_lines || text.chars().count() > preview.chars().count() {
        preview.push_str("[... folded in terminal view; full output kept in session history ...]");
    }
    preview
}

Spinner 的实现揭示了终端 UI 的一个基本约束:你不能在 async 等待的同时更新屏幕。解决方案是启动一个独立线程做旋转动画,通过 AtomicBool 信号停止:

rust
let handle = thread::spawn(move || {
    let frames = ["|", "/", "-", "\\"];
    let mut index = 0usize;
    while !signal.load(Ordering::Relaxed) {
        print!("\rworking{} waiting for model response", frames[index % frames.len()]);
        io::stdout().flush().ok();
        thread::sleep(Duration::from_millis(120));
        index += 1;
    }
    print!("\r{}\r", " ".repeat(64)); // 清除 spinner 行
});

这种"独立线程 + 原子信号"模式是终端 spinner 的标准做法。\r 回到行首覆盖写入,停止时用空格清除残留字符。


19.2 流式渲染与交互设计

流式 Markdown 渲染

LLM 的输出是 token-by-token 到达的。用户期望看到实时打字效果,但 Markdown 的语法结构(代码块、列表、表格)要求"看到完整结构才能正确渲染"。这产生了一个根本矛盾:渲染的正确性需要完整输入,但用户体验需要即时反馈

Codex TUI 的解决方案是 MarkdownStreamCollector——一个基于换行符的增量渲染器:

核心策略:只渲染已完成的逻辑行。收到每个 delta token 后追加到内部 buffer,然后找到最后一个换行符位置,只渲染换行符之前的内容。尚未以换行符结尾的末行被视为"不完整",暂不渲染。

text
Token 流:  "## H" → "ello\n" → "- it" → "em 1\n" → "- item" → " 2\n"
                        ↑ 提交渲染          ↑ 提交渲染           ↑ 提交渲染

commit_complete_lines() 的逻辑:

  1. 在 buffer 中找到最后一个 \n 的位置
  2. \n 之前的全部内容调用 Markdown 渲染器(pulldown-cmark)
  3. 比对上次已提交的行数,只返回新增的行
  4. 更新已提交行数计数器

流结束时调用 finalize_and_drain():如果 buffer 末尾没有换行符,强制追加一个,然后渲染并返回所有未提交的行。

这个方案的精妙之处在于重复渲染但只取增量。每次 commit 都从头渲染整个 buffer(而不是试图增量解析 Markdown),但只返回新增的行。这避免了增量 Markdown 解析的复杂性——Markdown 的上下文敏感语法(如多行代码块的 ``` 配对)让真正的增量解析极其困难。

渲染器本身使用 pulldown-cmark 解析 Markdown AST,然后转换为 ratatui 的 Line<'static> / Span<'static> 结构。对不同元素应用不同样式:

Markdown 元素终端样式
# H1bold + underline
## H2bold
### H3bold + italic
`code`cyan
**strong**bold
*emphasis*italic
[link](url)cyan + underline
> blockquotegreen

代码块使用 highlight_code_to_lines 做语法高亮,根据语言标识(```python 等)选择对应的高亮规则。

输入编辑器

终端输入编辑器要解决的核心问题:如何在一个字符网格中实现多行编辑、历史回溯、自动补全三件事。

多行输入。传统 CLI 用 Enter 提交,但 Agent 场景中用户经常需要粘贴多行代码或写多段指令。Codex TUI 的解决方案:Enter 提交,Alt+Enter(或配置的快捷键)插入换行。OpenCode TUI 的 textarea 支持完整的编辑操作集:

text
submit | newline | move-left/right/up/down | select-* |
line-home/end | buffer-home/end | delete-line |
delete-to-line-end/start | backspace/delete |
undo/redo | word-forward/backward | select-word-* |
delete-word-forward/backward

所有快捷键通过配置文件映射,支持 Ctrl / Meta / Shift / Super 修饰键组合。

历史回溯。Codex TUI 维护双层历史:

  • 持久化历史:跨会话,存储在 ~/.codex/history.jsonl,仅保存纯文本
  • 本地历史:当前会话,保存完整提交状态(文本、text elements、图片附件、粘贴占位符)

Up/Down 箭头在两层历史中导航。设计约束:当编辑区有非空内容时,只有在内容与上次回溯条目匹配且光标在边界位置时,才将 Up/Down 视为历史回溯——否则作为光标移动处理。这避免了多行编辑与历史回溯的冲突。

粘贴检测。Windows 终端不可靠地支持 bracketed paste,多行粘贴可能被终端拆成一连串快速的 Char + Enter 事件。Codex TUI 实现了 PasteBurst 状态机来检测这种情况:

text
Idle → [fast char] → Pending → [another fast char] → Active Buffer → [timeout] → Flush as paste
                         ↓ [slow/non-char]
                      Flush as typed char

核心判据:字符到达间隔是否低于阈值。一旦判定为粘贴,Enter 不再触发提交,而是作为换行追加到缓冲区。检测窗口结束后,整个缓冲区作为一次 handle_paste() 调用提交。

Slash Commands 设计

Slash commands 是终端 Agent 的核心交互模式之一。用户输入 / 触发命令弹窗,支持 fuzzy match 过滤。

Codex TUI 定义了 40+ 个内建 slash command,按使用频率排序(非字母序——这是有意为之的设计决策):

分类命令描述
模型控制/model /fast选择模型和推理强度
会话管理/new /resume /fork /rename创建/恢复/分叉/重命名会话
代码审查/review /diff审查变更、显示 diff
上下文控制/compact /mention压缩上下文、引用文件
权限管理/approvals /permissions配置工具执行权限
系统命令/status /clear /quit状态查看、清屏、退出

每个命令有三个元数据属性:

  • description:弹窗中的说明文本
  • supports_inline_args:是否支持 /review branch_name 形式的内联参数
  • available_during_task:任务运行中是否可用(如 /copy/diff 可以,但 /compact/model 不行)

命令的可见性还受 feature flag 控制。例如 /fast 只在 fast_command_enabled 为 true 时出现,/realtime 只在实验性语音模式启用时可见。

OpenCode 的 slash command 采用不同架构:命令不是硬编码的枚举,而是动态聚合自三个来源:

  1. 内建命令command source):/init(创建 AGENTS.md)、/review(代码审查)
  2. MCP promptsmcp source):从 MCP Server 的 prompts/list 动态获取
  3. Skillsskill source):从 skill 配置目录加载

每个命令本质上是一个 template string,支持 $1$2 等位置参数和 $ARGUMENTS 通配参数。执行时将用户输入替换到模板中,作为 prompt 发送给 Agent。

会话切换

会话管理是终端 Agent 区别于一次性 chat 的关键能力。

Codex TUI 的 /resume 命令打开一个会话列表弹窗,显示历史会话的标题和时间。选择后恢复该会话的完整上下文(包括消息历史和工具状态)。/fork 从当前会话分叉出新会话,继承截止到当前点的所有消息。

OpenCode 的会话管理通过 TUI 事件总线驱动:

typescript
const TuiEvent = {
  CommandExecute: BusEvent.define("tui.command.execute", z.object({
    command: z.union([
      z.enum(["session.list", "session.new", "session.share",
              "session.interrupt", "session.compact", ...]),
      z.string(),
    ]),
  })),
  SessionSelect: BusEvent.define("tui.session.select", z.object({
    sessionID: SessionID.zod,
  })),
}

TUI 层发出事件,由 worker 进程中的 Server 处理。这种解耦意味着会话切换不需要重建整个 TUI——只需通知 Server 切换到目标 session,然后通过 SSE 事件流接收新会话的状态更新。


19.3 Client-Server 架构

为什么需要 Client-Server 分离

传统 CLI 工具是单进程的:UI 逻辑和业务逻辑在同一个进程中。但 AI Agent 的复杂性打破了这个模式:

  • Agent 的后台任务(工具执行、文件监控、LSP 集成)可能运行很长时间
  • 用户可能想从多个终端/设备同时查看同一个 Agent 的状态
  • TUI 进程崩溃不应丢失 Agent 状态
  • 需要支持 headless 运行(CI/CD、远程服务器场景)

这些需求推动了 Client-Server 分离架构。

OpenCode 对比

Server 端架构

Codex App Server 是一个 Rust 实现的本地服务器,持有 Agent 的全部状态。启动时接受一个 --listen 参数决定传输方式:

text
codex-app-server --listen stdio://          # VSCode 扩展模式
codex-app-server --listen ws://127.0.0.1:0  # WebSocket 模式

Server 内部分为两个异步任务循环:

mermaid
graph LR
    subgraph Server Process
        TR[Transport Layer<br>stdio / WebSocket] -->|TransportEvent| PL[Processor Loop<br>JSON-RPC dispatch]
        PL -->|OutgoingEnvelope| OL[Outbound Loop<br>per-connection routing]
        OL --> TR
    end
    C1[Client 1<br>TUI] <-->|JSON-RPC| TR
    C2[Client 2<br>IDE] <-->|JSON-RPC| TR
  • Processor Loop:处理入站 JSON-RPC 消息,dispatch 到对应的 handler(thread 管理、config API、model 调用等)
  • Outbound Loop:管理每个连接的写端状态,将 OutgoingEnvelope 路由到正确的连接

两个循环通过 bounded channel(容量 128)通信,不共享可变状态。连接的开闭通过 OutboundControlEvent 枚举协调:

rust
enum OutboundControlEvent {
    Opened { connection_id, writer, allow_legacy_notifications, ... },
    Closed { connection_id },
    DisconnectAll,  // 优雅重启时断开所有连接
}

OpenCode Server 是 TypeScript 实现,基于 Hono 框架构建 HTTP API。它暴露一套 RESTful + SSE 接口:

路由方法用途
/sessionGET/POST/DELETE会话 CRUD
/session/:id/messageGET/POST消息读写
/session/:id/abortPOST中断运行中的任务
/session/:id/forkPOST分叉会话
/eventGET (SSE)实时事件流
/commandGET列出可用命令
/agentGET列出 Agent 配置
/providerGET列出 Provider

Server 有两种运行模式:

  1. In-process:TUI 进程内直接调用 Server 的 fetch 方法,无网络开销。通过 Bun Worker 实现进程内 IPC。
  2. Network:监听 TCP 端口,支持 Basic Auth 认证,支持 mDNS 发现。
typescript
// In-process 模式:Worker 进程直接调用 Server.Default().fetch
const fetchFn = async (input, init) => {
    const request = new Request(input, init);
    return Server.Default().fetch(request);
};

// Network 模式:Bun.serve 监听端口
const server = Bun.serve({
    hostname: opts.hostname,
    port: opts.port,
    fetch: app.fetch,
});

Client 端架构

TUI Client 不直接与 Agent 通信,而是通过 Server 的 API。OpenCode 的 TUI 启动流程:

  1. 主进程(TUI 渲染线程)启动一个 Bun Worker 作为 Server 后端
  2. Worker 初始化 Server 实例,开始监听事件
  3. 主进程通过 RPC 与 Worker 通信
  4. 根据是否需要网络访问,选择 in-process fetch 或 TCP 端口
typescript
// in-process 路径:无网络,直接 RPC 调用 Worker
const transport = {
    url: "http://opencode.internal",
    fetch: createWorkerFetch(client),  // RPC proxy
    events: createEventSource(client), // RPC event forwarding
};

// network 路径:Worker 启动 HTTP server
const transport = {
    url: (await client.call("server", networkOpts)).url,
    fetch: undefined,     // 使用标准 fetch
    events: undefined,    // 使用标准 EventSource
};

Attach 模式 允许 TUI 连接到已运行的远程 Server:

bash
opencode attach http://192.168.1.100:4096 --password secret

这使得以下工作流成为可能:在远程服务器上启动 opencode serve,然后从本地笔记本电脑 opencode attach 连接——Agent 的计算密集操作(LLM 调用、文件操作)在服务器上执行,TUI 仅负责渲染。

SSE 广播:多客户端实时同步

Server 端的事件广播通过 Server-Sent Events (SSE) 实现。

事件总线架构

mermaid
graph TB
    subgraph Server
        Agent[Agent Loop] -->|Bus.publish| EB[Event Bus<br>Bus.subscribeAll]
        EB -->|GlobalBus.emit| GE[Global EventEmitter]
    end
    GE -->|SSE| C1["/event endpoint<br>Client 1"]
    GE -->|SSE| C2["/event endpoint<br>Client 2"]
    GE -->|SSE| C3["/event endpoint<br>Client 3"]

当 Agent 执行任何操作(创建消息、工具调用、权限请求等),通过 Bus.publish() 发布事件。Bus 将事件转发到两个地方:

  1. 本 Instance 内的所有订阅者(本地 subscriber)
  2. GlobalBus.emit("event", ...) 通知所有 SSE 连接

SSE endpoint 的实现:

typescript
app.get("/event", async (c) => {
    c.header("X-Accel-Buffering", "no");  // 禁止 nginx 缓冲
    c.header("X-Content-Type-Options", "nosniff");
    return streamSSE(c, async (stream) => {
        const q = new AsyncQueue<string | null>();
        // 连接建立时发送确认事件
        q.push(JSON.stringify({ type: "server.connected", properties: {} }));
        // 10 秒心跳防止代理超时
        const heartbeat = setInterval(() => {
            q.push(JSON.stringify({ type: "server.heartbeat", properties: {} }));
        }, 10_000);
        // 订阅所有 Bus 事件
        const unsub = Bus.subscribeAll((event) => {
            q.push(JSON.stringify(event));
        });
        // 通过 AsyncQueue 驱动 SSE 写入
        for await (const data of q) {
            if (data === null) return;
            await stream.writeSSE({ data });
        }
    });
});

设计要点:

  • X-Accel-Buffering: no:告诉 nginx 等反向代理不要缓冲 SSE 流,否则事件会被积攒后批量推送
  • 10 秒心跳:防止中间代理因为长时间无数据而关闭连接
  • AsyncQueue:将事件从 Bus 的同步回调桥接到 SSE 的异步写入,解耦生产者和消费者速率

事件类型使用 discriminated union 定义,通过 Zod schema 在编译时保证类型安全:

typescript
export function define<Type extends string, Properties extends ZodType>(
    type: Type, properties: Properties
) {
    return { type, properties };
}
// 所有已注册的事件类型自动生成 discriminated union
export function payloads() {
    return z.discriminatedUnion("type",
        registry.entries().map(([type, def]) =>
            z.object({ type: z.literal(type), properties: def.properties })
        ).toArray()
    );
}

断线恢复

SSE 客户端实现了自动重连逻辑:

typescript
async function workspaceEventLoop(space, stop) {
    while (!stop.aborted) {
        const res = await adaptor.fetch(space, "/event", { signal: stop })
            .catch(() => undefined);
        if (!res || !res.ok || !res.body) {
            await sleep(1000);  // 连接失败,1 秒后重试
            continue;
        }
        await parseSSE(res.body, stop, (event) => {
            GlobalBus.emit("event", { directory: space.id, payload: event });
        });
        await sleep(250);  // SSE 流断开,250ms 后重连
    }
}

SSE 解析器处理底层流分帧:

typescript
export async function parseSSE(body, signal, onEvent) {
    const reader = body.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    while (!signal.aborted) {
        const chunk = await reader.read();
        if (chunk.done) break;
        buf += decoder.decode(chunk.value, { stream: true });
        buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
        const chunks = buf.split("\n\n");  // SSE 事件以双换行分隔
        buf = chunks.pop() ?? "";          // 最后一段可能不完整
        chunks.forEach((chunk) => {
            const data = [];
            chunk.split("\n").forEach((line) => {
                if (line.startsWith("data:")) data.push(line.replace(/^data:\s*/, ""));
                if (line.startsWith("id:")) last = line.replace(/^id:\s*/, "");
                if (line.startsWith("retry:")) { /* 更新重连间隔 */ }
            });
            if (data.length) onEvent(JSON.parse(data.join("\n")));
        });
    }
}

当前方案的一个已知限制:断线期间的事件会丢失。SSE 标准定义了 id 字段和 Last-Event-ID header 来支持断点续传,但当前实现没有使用这个机制。客户端重连后从 server.connected 事件重新开始,依赖上层(TUI)通过 API 轮询补齐缺失状态。

多客户端一致性问题

当多个客户端连接到同一个 Server 时,需要解决几个一致性问题:

1. 连接生命周期管理

Codex App Server 使用 ConnectionId 标识每个连接,跟踪其初始化状态:

rust
struct ConnectionState {
    session: ConnectionSessionState,
    outbound_initialized: Arc<AtomicBool>,
    outbound_experimental_api_enabled: Arc<AtomicBool>,
    outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
}

每个连接有独立的 initialized 标志和 feature flag。只有完成 initialize 握手的连接才会收到事件通知。这允许不同客户端(TUI、IDE、Web)以不同的 capability 集连接同一个 Server。

2. 优雅关闭

WebSocket 模式下,Server 收到 SIGTERM 时不立即退出,而是进入 graceful drain 状态:

rust
fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) {
    self.requested = true;
    info!("received shutdown signal; entering graceful restart drain \
           (connections={}, runningAssistantTurns={})", connection_count, running_turn_count);
}
fn update(&mut self, running_turn_count: usize, connection_count: usize) -> ShutdownAction {
    if self.forced || running_turn_count == 0 { return ShutdownAction::Finish; }
    ShutdownAction::Noop  // 等待所有 assistant turn 完成
}

第一个 SIGTERM:等待运行中的 assistant turn 完成。第二个 SIGTERM:强制关闭所有连接。

3. stdio 与 WebSocket 的行为差异

行为stdio 模式WebSocket 模式
客户端数量恰好 1 个0..N 个
连接断开时Server 退出仅移除该连接
优雅关闭不支持(无 SIGTERM 处理)支持 drain 等待
认证不需要(同一进程)需要(Origin header 检查)

WebSocket 模式额外拒绝包含 Origin header 的请求——这是防止浏览器跨站 WebSocket 劫持的安全措施。


19.4 多端 UI

终端 TUI

终端 TUI 是 AI Coding Agent 的原生栖息地。Codex 使用 Rust + ratatui 构建,OpenCode 使用 TypeScript + 自研 TUI 框架(@opentui/core)。

两者的核心 UI 布局类似:

python
┌──────────────────────────────────────┐
│ Status Bar: model / tokens / branch  │
├──────────────────────────────────────┤
│                                      │
│  Message History                     │
│  (scrollable, Markdown rendered)     │
│                                      │
│  assistant> Here's the fix:          │
```python                           │
def solve(n):                       │
return n * (n + 1) // 2
```                                 │
│                                      │
├──────────────────────────────────────┤
> Input area (multi-line)       [?]  │
└──────────────────────────────────────┘

Codex TUI 的 ratatui 架构分为多个 widget 层:

  • ChatWidget:顶层容器,管理上下窗格的比例
  • MarkdownStreamCollector:将流式 token 转化为 ratatui Line 序列
  • ChatComposer:底部输入框,集成 paste burst 检测、slash command 弹窗、文件 mention 弹窗
  • SelectionList:fuzzy search 弹窗的通用列表组件

Desktop 应用

Tauri 路线:OpenCode 的 CORS 配置揭示了 Tauri 集成的设计:

typescript
origin(input) {
    if (input === "tauri://localhost" ||
        input === "http://tauri.localhost" ||
        input === "https://tauri.localhost") return input;
    if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input;
}

Tauri 应用的 WebView 发出的请求带有 tauri://localhosthttp://tauri.localhost origin。Server 专门白名单这些 origin,使 Tauri 包装的 Web 前端可以直接调用本地 Server API。

Electron 路线:Codex 的 App Server 设计天然支持 Electron 集成——通过 ws:// transport 连接到本地 Server,UI 完全在 Electron 的 renderer 进程中实现。

Web 界面

OpenCode 支持通过浏览器直接访问:

bash
opencode web                              # 自动开浏览器
opencode web --hostname 0.0.0.0 --port 4096  # 局域网可访问
opencode web --mdns                       # mDNS 广播,手机可发现

Web 模式的实现极为精简:Server 在处理完所有 API 路由后,将未匹配的请求代理到 app.opencode.ai

typescript
app.all("/*", async (c) => {
    const response = await proxy(`https://app.opencode.ai${c.req.path}`, {
        ...c.req,
        headers: { ...c.req.raw.headers, host: "app.opencode.ai" },
    });
    response.headers.set("Content-Security-Policy",
        "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; ...");
    return response;
});

这意味着 Web 前端的静态资源托管在云端(app.opencode.ai),本地 Server 只做 API 后端和反向代理。CSP header 限制了前端只能加载同源资源和 WASM,防止 XSS 攻击。

从手机或平板上访问 http://server-ip:4096 就能获得完整的 Agent 交互界面。--mdns 选项通过 mDNS 广播服务地址,使同一局域网的设备无需记住 IP 就能发现并连接。

IDE 集成

Codex App Server 的 --session-source 参数声明了客户端身份:

bash
codex-app-server --listen stdio:// --session-source vscode

不同的 session source 可能影响 Server 的行为(如 analytics、feature gates)。当前支持的 source 包括 vscodeterminal 等。

IDE 集成的典型模式是:IDE 扩展 spawn 一个 App Server 进程,通过 stdio 或 WebSocket 与其通信。Server 管理所有 Agent 状态,扩展只负责 UI 渲染和用户交互转发。这与 LSP(Language Server Protocol)的架构思想一脉相承——IDE 不需要理解 Agent 的内部逻辑,只需实现标准化的消息协议。

架构对比总结

维度CodexOpenCode
TUI 实现Rust (ratatui)TypeScript (@opentui/core)
Server 语言Rust (axum + tokio)TypeScript (Hono + Bun)
通信协议JSON-RPC over stdio/WebSocketREST + SSE over HTTP
多客户端支持WebSocket 模式原生支持HTTP 天然支持
实时推送WebSocket 双向SSE 单向推送
Desktop通过 WebSocket 接入Tauri WebView 接入
Web不直接支持内建 opencode web
远程连接SSH port-forwardingopencode attach + Basic Auth
断线恢复WebSocket 重连SSE 自动重连 + API 轮询

两种架构各有取舍:Codex 的 JSON-RPC + WebSocket 方案更适合低延迟双向通信(如实时 agent turn 的流式输出),OpenCode 的 REST + SSE 方案更 Web-native,浏览器/移动端接入零成本。

最终,无论哪种架构,核心设计原则是一致的:Server 持有 Agent 状态,Client 只是渲染层;多个 Client 可以连接同一个 Server;Client 断线不影响 Agent 执行。这种分离使得终端、桌面、Web、手机端能够用不同的技术栈实现最优的 UI 体验,同时共享同一个 Agent 后端。