# Web 与服务端：同一套 UI 的另一种宿主

> zcode --web 与 pnpm dev:web 各起了什么、令牌怎样生效；Hono 服务怎样经仿 VS Code 的 RPC 把业务服务交给浏览器、拉起 Agent 并推回事件；同一套 React UI 如何靠依赖注入同时跑在 Electron 与浏览器里，架构规则又管到了哪里。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/server-web
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

桌面端之外，ZCode 还能不带 Electron 运行：`zcode --web` 在本机起一个 Node 服务，浏览器打开就是和桌面端几乎一样的工作台。两者用的是同一套 React 界面（`packages/ui`）、同一套业务服务（`packages/services`）和同一个 Agent CLI，区别只在宿主——桌面端的 Renderer 经 MessagePort 连本窗口的 Host 进程（见[桌面应用](https://daiw.org/manual/zcode/desktop)），Web 模式的浏览器经 WebSocket 连这个 Node 服务。

这一篇沿着 Web 模式走一遍：服务怎么起、令牌怎么生效；`packages/rpc` 这套仿 VS Code 的 IPC 框架怎样把几十个服务搬到网络另一端；`packages/services` 的服务描述符与服务目录；`packages/ui` 怎样用依赖注入同时适配两种宿主；最后看架构规则把这些边界管到了哪一步。代码分布在 `packages/server`、`packages/zcode-server-cli`、`packages/rpc`、`packages/client`、`packages/web` 以及上面三个大包里，各包规模见[仓库全景](https://daiw.org/manual/zcode/monorepo-map)；`packages/server/src` 的 48 个文件里有 38 个在 `remote/` 下，那部分归[下一篇](https://daiw.org/manual/zcode/remote)。

## 两种起法

README 给了两个入口：改 Web 或后端源码时用 `pnpm dev:web`，验证发行包时用 `zcode --web`（`README.md:74`、`README.md:93`）。它们起的东西并不一样：

| | `pnpm dev:web` | `zcode --web` |
| --- | --- | --- |
| 进程 | `concurrently` 同时跑后端的 tsup 监听构建与 Web 的 Vite（`package.json:8`） | 分流脚本 `bin/zcode.mjs` 起一个 `node server/entry-http.js` 子进程（`scripts/zcode-distribution/runner.mjs:178`） |
| 页面从哪来 | Vite 的 `5173`；`/ws` 与 `/api` 代理到 `3030`，`/api/v1/oauth/token` 单独代理到产品服务（`packages/web/vite.config.ts:56`） | 后端自己托管 `web/` 静态文件，找不到文件时回退到 `index.html`（`packages/server/src/entry-http.ts:24`） |
| 监听地址 | 不指定（见下方提示） | 默认 `127.0.0.1`（`runner.mjs:37`） |
| 端口 | `PORT`，缺省 `3030`（`entry-http.ts:13`） | `--port`；缺省时先在该地址上占一个空闲端口再交给后端 |
| 工作区 | `ZCODE_SERVER_WORKSPACE`，缺省为后端进程的当前目录（`packages/server/src/http.ts:157`） | `--workspace`，缺省为当前目录 |
| 访问令牌 | 只认 `ZCODE_SERVER_AUTH_TOKEN`，默认没有（`entry-http.ts:16`） | 按下面的规则自动决定 |

开发模式的工作区缺省值值得留意：pnpm 在包目录里执行脚本，不设 `ZCODE_SERVER_WORKSPACE` 时打开的就是 `packages/server` 自己，所以 README 的示例特意带上了这个变量。`zcode --web` 的核心逻辑在 `runner.mjs:172`：

```js
  const port = options.port && options.port > 0 ? options.port : await pickPort(options.host);
  const protect = options.tokenEnabled ?? shouldProtectHost(options.host);
  const token = protect ? (options.token ?? createToken()) : "";
  const open = options.open ?? isLocalHost(options.host);
  const localUrl = formatUrl(options.host, port, token);

  const child = spawn(process.execPath, [serverEntry], {
    cwd: options.workspace,
    env: {
      ...process.env,
      PORT: String(port),
      ZCODE_AGENT_SERVER_ARGS_JSON: JSON.stringify([agentEntry, "app-server", "--stdio"]),
      ZCODE_AGENT_SERVER_COMMAND: process.execPath,
      ZCODE_SERVER_HOST: options.host,
      ZCODE_SERVER_WORKSPACE: options.workspace,
      ZCODE_WEB_STATIC_ROOT: webRoot,
      // 显式关闭 token 时必须清空继承值，否则 --no-token 仍会开启后端鉴权。
      ZCODE_SERVER_AUTH_TOKEN: token,
    },
    stdio: ["ignore", "pipe", "pipe"],
  });
```

- **令牌**：显式的 `--token <值>` 或 `--no-token` 优先；否则只要监听地址不是 `127.0.0.1`、`localhost`、`::1` 这三种写法之一，就生成一个 24 字节随机数的 base64url 令牌（`runner.mjs:101`、`runner.mjs:110`），与 README 的说法一致（`README.md:103`）。
- **浏览器**：是否自动打开同样默认看是不是本机地址，打开前等 500 毫秒；监听 `0.0.0.0` 或 `::` 时，终端还会列出每张 IPv4 网卡的带令牌地址（`runner.mjs:214`、`runner.mjs:222`）。`Ctrl+C` 给后端发 `SIGTERM`，1.5 秒后分流脚本自行退出（`runner.mjs:226`）。
- **终端输出**：后端的标准输出原样转到终端（`runner.mjs:199`），而后端给每条 RPC 连接套的日志中间件用的是 `console.log`（`src/http.ts:80`、`src/http.ts:92`），所以终端里会逐条刷出 `[rpc:call] 频道.方法 OK (耗时)` 这样的记录。
- **偏好**：主题等界面偏好存在浏览器的 localStorage 里（`packages/ui/src/store/index.ts:257`），而 localStorage 按协议、主机与端口隔离。从代码看，不固定 `--port` 时每次启动都换一个源，这些偏好不会延续。

<Callout type="warn">
  `pnpm dev:web` 的后端不指定监听地址：不设 `ZCODE_SERVER_HOST` 或 `HOST` 时 `hostname` 为 `undefined`（`entry-http.ts:14`、`src/http.ts:416`），`@hono/node-server` 把它原样交给 Node 的 `server.listen`，按 Node 的语义就是监听所有地址，日志里的 `localhost` 只是显示用的缺省值（`src/http.ts:419`）。开发模式又默认没有令牌，同一网络里的其他机器（没有防火墙拦截时）也能连到 `3030`。此外 `/ws` 升级时不检查 `Origin`（`src/http.ts:322`），WebSocket 也不受同源策略约束，无令牌的本机模式能依靠的只有“不在本机就连不上”和“端口不好猜”。在不可信的环境里，开发时设 `ZCODE_SERVER_HOST=127.0.0.1`，发行包加 `--token`。
</Callout>

## 路由与令牌

后端是一个 Hono 应用，路由都在 `createHttpServer` 里注册（`src/http.ts:295`）：

| 路由 | 作用 |
| --- | --- |
| `GET /api/server-info` | 服务 ID、版本、协议版本 1、工作区列表（`src/http.ts:317`，结构见 `packages/shared/src/server-remote.ts:11`）；浏览器取第一个工作区作为初始工作区（`packages/web/src/main.tsx:377`） |
| `POST /api/rpc-host-capability` | 签发可信 Host 用的一次性票据（`src/http.ts:318`） |
| `GET /ws` | 浏览器的 RPC 连接，固定按 `web-remote-replayable` 处理（`src/http.ts:322`） |
| `GET /ws/host` | 出示票据的可信 Host 连接，按 `desktop-continuous` 处理（`src/http.ts:331`） |
| `POST /api/connect-remote`、`GET /ws/remote/:id` | 由后端发起 SSH 等远程连接，再把远端的文件、Git、系统、终端四个服务桥接给浏览器（`src/http.ts:346`、`src/http.ts:368`） |
| `GET *` | 静态文件；`index.html` 用 `no-cache`，其余一年 `immutable`（`src/http.ts:399`） |

令牌中间件只在配置了令牌时才挂上（`src/http.ts:305`），受保护的只有 `/ws`、`/ws/` 开头与 `/api/` 开头三类路径（`src/http.ts:236`）。静态文件不受限，没带令牌也能加载页面，只是随后的 `/api/server-info` 与 `/ws` 会得到 401，页面落到“Web 启动失败”的提示（`web/src/main.tsx:390`）。令牌从 URL 的 `?token=` 进来，命中后服务端顺手种一个 `HttpOnly`、`SameSite=Lax` 的 Cookie，之后的同源请求靠它通过（`src/http.ts:224`），所以终端给的是带令牌的首页链接，页面连 WebSocket 时不必再带。这个 Cookie 叫 `zcode_lite_token`（`src/http.ts:184`）——命令行版的前身叫 Lite，README 还专门提醒“旧 Lite 用户需要改用上述构建命令、环境变量和新的安装脚本”（`README.md:187`）。

`/ws/host` 在令牌之外还要票据：32 字节随机数，30 秒有效，经 `x-zcode-rpc-host-capability` 请求头出示，无论成功、过期还是重放都先删除（`packages/server/src/hostCapability.ts:4`、`hostCapability.ts:24`、`hostCapability.ts:46`、`packages/shared/src/channels.ts:498`）。旧的 `x-zcode-rpc-client-mode` 头已经作废，普通 `/ws` 带什么头都只能当终端客户端（`channels.ts:495`、`src/http.ts:320`）。仓库里连 `/ws/host` 的只有独立 Server 的 SSH 验收脚本（`packages/zcode-server-cli/scripts/verify-remote-ssh.mjs:234`），桌面端的正式代码里没有找到调用方。还有一处小出入：`/api/server-info` 的 `authRequired` 在调用方没显式传值时去读 `ZCODE_SERVER_TOKEN`（`src/http.ts:174`），与真正开启鉴权的 `ZCODE_SERVER_AUTH_TOKEN` 不是同一个变量，只影响这个字段的取值。

## 三个宿主入口

`ServiceCollection` 不只在 Web 模式里上网。仓库里有三个 Node 入口把它挂到传输上，写法几乎一样：

| 入口 | 传输 | 连接角色 | 用在哪里 |
| --- | --- | --- | --- |
| `packages/server/src/entry-http.ts` | WebSocket | `/ws` 为终端客户端，`/ws/host` 为可信 Host 中继 | Web 模式 |
| `packages/server/src/entry-stdio.ts` | stdin 与 stdout | 一律可信 Host 中继（`packages/server/src/stdio.ts:65`） | 打包成 `dist/remote/zcode-server.cjs`（`packages/server/build-remote.ts:33`），部署到 SSH、WSL、Docker 远端 |
| `packages/zcode-server-cli/src/server-core/entry.ts` | WebSocket | 同 Web 模式 | 独立 ZCode Server 的 Server Core，由 Supervisor 拉起 |

stdio 入口先在 stdout 发一行 `zcode-hello`，10 秒内等不到 `hello-ack` 就失败（`packages/server/src/entry-stdio.ts:41`、`entry-stdio.ts:92`）；stdout 只能承载 RPC 帧，所以它把 `console.log`、`info`、`warn`、`debug` 一律改道 stderr（`entry-stdio.ts:24`）；退出分“停 RPC”和“回收服务”两段，各自限时 1 秒与 3.5 秒，保证 SSH 断开时 Agent 进程树也能被清理（`packages/server/src/stdio-lifecycle.ts:4`、`stdio-lifecycle.ts:78`）。独立 Server 的 CLI 提供 `serve`、`status`、`stop`、`restart`、`update`、`uninstall`（`packages/zcode-server-cli/src/cli.ts:89`），Server Core 以 `standalone-server` 权威模式创建服务（`packages/zcode-server-cli/src/server-core/core.ts:42`）；它不托管网页，也没有令牌中间件，因此拒绝监听回环以外的地址，注释说“对外监听必须 fail-closed”，访问靠 `ssh -L` 隧道（`packages/zcode-server-cli/src/server-core/http.ts:128`、`verify-remote-ssh.mjs:4`）。这个包不进命令行发行包（`scripts/build-zcode.mjs:177`）。远端部署、守护与升级见[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)。

## 一条 WebSocket 连接

每来一条连接，后端就新建一套协议与 `ChannelServer`，再把整个服务集合挂上去（`packages/server/src/http.ts:89`）：

```ts
  const protocol = new SocketProtocol(socket);
  const rawServer = new ChannelServer(protocol, "server");
  // 用日志中间件包装，统一记录所有 RPC 调用
  const server = new LoggingChannelServer(rawServer, log);
  const agentService = services.getOptional(IZCodeAgentService);
  const connectionScope = agentService
    ? createZCodeAgentConnectionScope(agentService, {
        connectionId: `server-ws-${randomUUID()}`,
        clientMode,
        role: clientMode === "desktop-continuous" ? "trusted-host-relay" : "terminal-client",
      })
    : undefined;
  const overrides = new Map<string, unknown>();
  if (connectionScope) {
    overrides.set(IZCodeAgentService.channelName, connectionScope.service);
  }
  // ...
  services.exposeOnChannelServer(server, overrides);
```

- 服务集合是进程级的：所有连接共享同一组服务实例、同一批 Agent 子进程，按连接替换的只有两个频道。Agent 服务换成“连接作用域”：它按 RPC attachment 记账订阅，要求终端客户端先完成 `hello` 与 `clientHello` 握手（`packages/services/src/zcode-agent/zcodeAgentConnectionScope.ts:228`、`zcodeAgentConnectionScope.ts:656`），命令里的 `clientId` 与握手绑定的不一致就拒绝（`zcodeAgentConnectionScope.ts:684`），流控这类接口只许可信中继调用（`zcodeAgentConnectionScope.ts:666`）。省略的那段把 Provider Provisioning 对非 `desktop-continuous` 连接换成一个只会抛错的桩，注释的理由是它“携带跨 Environment 凭据，只允许 Desktop trusted host 使用”（`src/http.ts:105`）。
- 连接关闭时释放作用域与 `ChannelServer`（`src/http.ts:118`）。作用域只退订自己名下的订阅（`zcodeAgentConnectionScope.ts:1015`），Agent 进程不受影响，刷新页面、重新订阅就能接上。
- 桌面 Host 的 MessagePort 连接是同一套写法，多挂了网络遥测中间件，替换的频道也更多（`packages/desktop/src/host/index.ts:1978`、`host/index.ts:1990`）。

```mermaid
flowchart LR
  subgraph BR["浏览器"]
    UI["Root（packages/ui）"] --> ACC["RemoteServiceAccess<br/>每个服务一个代理"]
    WP["createWebPlatform()"] --> UI
    ACC --> CC["ChannelClient<br/>SocketProtocol"]
  end
  subgraph SV["后端进程 entry-http.js"]
    MW["Hono 路由与令牌中间件"] --> CS["每条连接一个 ChannelServer<br/>套一层日志中间件"]
    CS --> SC["ServiceCollection<br/>createLocalServices()"]
    CS --> SCOPE["Agent 连接作用域"]
    SCOPE --> AGS["ZCodeAgentService"]
    MW --> ST["静态文件与 SPA 回退"]
  end
  RUN["bin/zcode.mjs"] -.->|"spawn 并注入环境变量"| MW
  ST -.->|"页面与脚本"| UI
  CC -->|"/ws 上的二进制帧"| MW
  AGS -->|"stdin 命令"| CLI["zcode.cjs app-server --stdio<br/>每个工作区一个进程"]
  CLI -->|"stdout 响应与 v4 帧"| AGS
```

Agent 进程由 `ZCodeAgentProcessManager` 按工作区键管理，一个工作区一个进程，命令解析链的细节见[桌面应用](https://daiw.org/manual/zcode/desktop)。与 Web 模式有关的只有三点：`zcode --web` 用 `ZCODE_AGENT_SERVER_COMMAND` 与 `ZCODE_AGENT_SERVER_ARGS_JSON` 占住解析链的第一位（`packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:441`）；`pnpm dev:web` 落到第二位，从当前目录向上找 `apps/zcode-cli/packages/cli/dist/zcode.cjs`，找不到才用 `tsx` 直接跑源码（`zcodeAgentProcessManager.ts:356`、`zcodeAgentProcessManager.ts:379`），这就是 README 要求“Agent 源码修改后，执行 `pnpm --filter @zcode/cli... build` 并重启服务”的原因（`README.md:82`）；Web 模式没有桌面端的装配事实，Agent 不带 `--surface desktop`，以默认的终端呈现运行（`packages/services/src/zcode-agent/zcodeAgentPresentationSurface.ts:15`）。

事件回流的路径是这样的：Agent 在 stdout 上发来 `v4/conversation/frame` 通知，服务端按 topic 前缀分成会话、会话列表、工作区配置三路，由对应工作区的 Emitter 发出（`packages/services/src/zcode-agent/zcodeAgentService.ts:2050`、`zcodeAgentService.ts:2088`）；连接作用域只把本连接订阅过的帧转下去（`zcodeAgentConnectionScope.ts:830`）；浏览器里的会话传输层订阅动态事件 `onDynamicConversationFrame`（`packages/ui/src/v4/agentConversationTransport.ts:546`），落到 RPC 上就是一次 `EventListen` 请求加源源不断的 `EventFire` 响应。帧的语义、快照与重放见 [ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol)。

## RPC：从 VS Code 搬来的 IPC 骨架

`@zcode/rpc` 的自我描述是“VS Code style IPC communication abstraction framework”（`packages/rpc/package.json:4`）。这不只是风格上的借鉴：`third-party/copied-components.json` 把 `packages/rpc/src`、`packages/rpc/examples` 与协议 V4 的 `wire-codec.ts` 登记为源自 VS Code IPC 的 MIT 代码，并划了范围（`third-party/copied-components.json:226`）：

> Upstream-derived IPC, serialization, buffer, lifecycle and event portions only. Local RPC extensions, transports and envelope measurement are ZCode adaptations; inclusion does not attribute all local code to Microsoft.

入口文件的注释画了七层（`packages/rpc/src/index.ts:6`）。对照 `packages/` 与 `apps/` 下非测试代码的实际 import：

| 层 | 内容 | 生产代码用到没有 |
| --- | --- | --- |
| 0 基础设施 | `Event`、`Emitter`、`DisposableStore`、`CancellationToken`、`VSBuffer` | 用，`Emitter` 遍布各包 |
| 1 序列化 | `serialize`、`deserialize` | 用 |
| 2 传输 | `SocketProtocol`、`MessagePortProtocol`、`PersistentProtocol` | 前两个用，`PersistentProtocol` 没有 |
| 3 Channel RPC | `ChannelServer`、`ChannelClient` | 用 |
| 4 连接管理 | `IPCServer`、`IPCClient`、`StaticRouter` | 没有 |
| 5 服务代理 | `ProxyChannel.fromService`、`toService` | 用 |
| 6 远程连接 | `RemoteAgentConnection` 等 | 没有 |
| 中间件 | `LoggingChannelServer`、`NetworkTelemetryChannelServer` | 用；两个 Client 侧版本没有 |

没人用的 `ipc.ts`、`remote.ts`、`persistent-protocol.ts` 合计 964 行，约占包的三成。

### 线上格式

两层编码。外层是 13 字节帧头：类型 1 字节，序号、确认号、长度各 4 字节，大端序（`packages/rpc/src/protocol.ts:184`）。`SocketProtocol` 只分帧，序号与确认号都填 0（`protocol.ts:253`）；WebSocket 本身有消息边界，但它和 stdio 共用这个类，所以也带着帧头。桌面的 `MessagePortProtocol` 不分帧，直接投递 `Uint8Array`（`protocol.ts:361`）。内层是带类型标签的序列化：每个值前面 1 字节类型，`undefined`、字符串、`Buffer`、`VSBuffer`、数组、对象、整数七种，长度用 VQL 变长整数，对象走 JSON（`packages/rpc/src/serialization.ts:109`）；对象里嵌套的 `Uint8Array` 原本会被 JSON 展开成 `{"0":…}`，ZCode 为此加了一层带标记的 base64 包装（`serialization.ts:180`）。每条消息是一个 header 数组加一个 body（`serialization.ts:148`），类型码定义在 `packages/rpc/src/channels.shared.ts:18`：

| 方向 | 类型码 | header |
| --- | --- | --- |
| 请求 | `Promise` 100、`PromiseCancel` 101、`EventListen` 102、`EventDispose` 103 | `[type, id, channelName, name]`，取消与退订只带 `[type, id]` |
| 响应 | `Initialize` 200、`PromiseSuccess` 201、`PromiseError` 202、`PromiseErrorObj` 203、`EventFire` 204 | `[type, id]`，`Initialize` 只有 `[type]` |

几条值得记住的行为：

- `ChannelServer` 一构造就发 `Initialize`，客户端收到之前的请求一律排队（`packages/rpc/src/channelServer.ts:29`、`packages/rpc/src/channelClient.ts:123`）；桌面 Host 连远程工作区时用 `deferInit` 把这一步推迟到远端服务就绪，否则 Renderer 立刻发出的请求会撞上“Unknown channel”（`host/index.ts:1974`）。
- 请求到达时频道还没注册，服务端先挂起，1000 毫秒后仍未注册就回 `Unknown channel` 错误（`channelServer.ts:25`、`channelServer.ts:229`）。客户端的代理对频道存不存在一无所知，缺失的服务只会在调用时以这种方式暴露。
- 错误跨进程时保留 `message`、`name`、`stack`，另透传 `code`、`kind`、`status`、`retryAfterMs`、`data`、`detail`、`details`、`taskId`、`traceId` 九个字段（`channelServer.ts:155`）。
- `ChannelClient` 自己不设超时，只在 `dispose` 时让所有挂起的请求失败（`channelClient.ts:249`）。

### ProxyChannel：用约定代替样板

`fromService` 把服务对象的方法映射成 `call`，把 `on` 加大写字母开头的属性当事件，`onDynamic` 开头的当作“带参数、返回事件的方法”（`packages/rpc/src/proxy-channel.ts:42`）；`toService` 用 ES6 `Proxy` 拦截属性访问，把调用变成 `channel.call`，并让 `Symbol` 与 `then` 走普通对象语义，免得 React 开发态的探测把服务当成 thenable（`proxy-channel.ts:112`）。`onDynamicConversationFrame(params)` 这种命名就是冲着这条约定来的：在浏览器里调用它，只是发出一次带参数的 `EventListen`。有一处注释与实现不符：`fromService` 说要把事件“buffer 化：即使没人订阅，事件也不会丢失”（`proxy-channel.ts:56`），但 `bufferEvent` 要等第一个订阅者出现才去订阅源事件（`proxy-channel.ts:183`），缓冲能兜住的只是订阅过程中同步触发的那些。

### 没接上的可重连协议，与两个中间件

`PersistentProtocol` 在帧头上加了 ACK、心跳与重放：每 5 秒一次 KeepAlive，20 秒收不到 ACK 视为断开；未确认字节超过 1 MiB 发出“饱和”，回落到四分之一再“解除”；重放缓冲超过 8 MiB 或最老的消息超过 45 秒，就放弃会话，让客户端回到语义层重新订阅（`packages/rpc/src/persistent-protocol.ts:77`、`persistent-protocol.ts:81`、`persistent-protocol.ts:92`）。注释里满是 v4 通道与“对端锁屏”的考虑，但仓库里没有生产代码用它。Web 模式走的是不带 ACK 的 `SocketProtocol`，传给 `connectViaWebSocket` 的 `onClose` 是个空函数（`web/src/main.tsx:446`），`packages/client` 里也没有重连逻辑。从代码看，WebSocket 一断，页面既不会自动重连，挂起的调用也不会结束，只能刷新；好在会话事实都在服务端，刷新后重新订阅即可恢复。

中间件都是装饰器：`LoggingChannelServer` 在注册频道时包一层，记录每次调用的耗时与成败（`packages/rpc/src/logging-middleware.ts:79`）；`NetworkTelemetryChannelServer` 以“频道.方法”为接口名记录耗时与错误类别，交给一个全局 sink，注释说是“供桌面主进程聚合上报 ARMS”（`packages/rpc/src/network-telemetry-middleware.ts:2`）。Web 服务端只挂前者，桌面 Host 两个都挂（`host/index.ts:1979`）。

为什么选这一套？注释给的理由是“腰部”：`IMessagePassingProtocol` 只有 `send` 与 `onMessage`，“只要实现 send() 和 onMessage，就能接入整个 RPC 框架”（`protocol.ts:8`）。ZCode 恰好有三种传输——桌面的 MessagePort、Web 的 WebSocket、远端的 stdio——每种只要一个四十行左右的 socket 包装（`packages/server/src/http.ts:41`、`stdio.ts:14`、`packages/client/src/websocket.ts:23`），上面的 Channel、代理与服务集合一行不改；`ProxyChannel` 又省掉了为每个服务手写分发的样板。对照 [OpenCode 的 HTTP 服务端](https://daiw.org/manual/opencode/server)：OpenCode 让所有前端走同一套 HTTP API，ZCode 的 Web 模式只用 HTTP 做引导，业务全在 WebSocket 上的二进制 RPC 里。

## services：描述符、集合与服务目录

服务的身份是描述符：`createServiceDescriptor<T>(channelName)` 只返回 `{ channelName }`，泛型参数是只在类型层存在的幻影类型；借助 TypeScript 允许同名的 interface 与 const 分属两个命名空间，调用方用同一个名字既指类型、又指描述符（`packages/services/src/descriptors.ts:4`、`descriptors.ts:14`）。频道名集中在 `ServiceChannels`，共 40 个（`channels.ts:74`），`packages/services` 里恰有 40 个描述符与之一一对应。注册中心只有几十行（`packages/services/src/collection.ts:9`）：

```ts
export class ServiceCollection {
  private readonly _services = new Map<string, unknown>();

  register<T>(descriptor: ServiceDescriptor<T>, instance: T): this {
    this._services.set(descriptor.channelName, instance);
    return this;
  }
  // ...
  /** 将所有已注册的服务自动暴露为 channel */
  exposeOnChannelServer(
    server: IChannelServer,
    overrides: ReadonlyMap<string, unknown> = new Map(),
  ): void {
    for (const [channelName, instance] of this._services) {
      const exposed = overrides.get(channelName) ?? instance;
      server.registerChannel(
        channelName,
        ProxyChannel.fromService(exposed as Record<string, unknown>),
      );
    }
  }
}
```

这是按频道名索引的服务定位器，不是 VS Code 那种由 `IInstantiationService` 做构造函数注入的容器：实例在组装根里手工创建、显式传依赖。Node 侧的组装根是 `createLocalServices`（`packages/services/src/node.ts:1281`），无条件注册 38 个服务（`services/src/node.ts:2422` 起）；`IProviderProvisioningTargetService` 只在远端 Host 模式或调用方明确开启时注册（`services/src/node.ts:2597`），Web 入口的开关就是“有没有配令牌”（`entry-http.ts:19`），注释写明“HTTP Server 只有在调用方明确配置认证时才暴露跨 Environment Provisioning target”（`services/src/node.ts:1324`）。桌面 Host 复用这个工厂，再补上它独有的 `IWindowControllerService`（`host/index.ts:1988`）。

客户端这边，`IServiceAccessor` 是 UI 看到的服务全集，38 个成员，4 个可选（`packages/services/src/accessor.ts:43`）。`RemoteServiceAccess` 为每个成员调一次 `ProxyChannel.toService`，注释说“新增服务只需在此添加一个 getter”（`packages/client/src/remoteServiceAccess.ts:48`）；只给可信 Host 用的 Provisioning 代理设成不可枚举，也不在 accessor 的类型里（`remoteServiceAccess.ts:157`）。代理不管服务端注册了什么：Web 服务端没有 `window-controller` 频道，浏览器里的 `windowControllerService` 却照样存在，调用它要等 1 秒才以 `Unknown channel` 失败。

`packages/services` 与 `packages/shared` 都分出两个入口：根入口只放接口、描述符与浏览器可用的实现（`packages/services/src/index.ts:1` 的注释是“browser-safe”），Node 实现走 `./node` 子路径（`packages/services/package.json:13`）；`packages/shared/src/node.ts:4` 则写明“This subpath must not be imported by renderer/browser bundles.”。`packages/ui` 里对 `@zcode/services` 的 188 条 import 全部指向根入口，其中 178 条是纯类型导入。服务目录按规模排：

| 目录 | 行数 | 描述符 | 职责 |
| --- | --- | --- | --- |
| `zcode-agent/` | 19592 | `IZCodeAgentService`（116 个成员，`packages/services/src/zcode-agent/zcodeAgent.ts:566`） | Agent 子进程与 ZCode Protocol 客户端、v4 帧分发、连接作用域 |
| `session/`、`zcode-session/` | 12210 | `IZCodeTaskService`、`IOffPeakTaskService`、`IZCodeSessionService` | 任务包装与任务索引、自动化与闲时任务、会话应用服务 |
| `skills/`、`plugins/`、`subagents/`、`commands/`、`hooks/`、`memory/` 与三个 `*-sync/` | 8729 | 10 个 | 扩展与记忆的管理，以及向远端同步 |
| `oauth/`、`usage-stats/`、`coding-plan-subscription/` | 7962 | 3 个 | 登录、用量统计、套餐购买 |
| `model-provider/`、`providers/` | 6034 | `IProviderSettingsService`、`IModelSelectionService`、`IProviderProvisioningTargetService` | Provider 配置视图、模型选择、向远端 Environment 下发配置 |
| `conversation-share/` | 5482 | `IConversationShareService` | 会话分享的发布、预览与续聊 |
| `git/` | 4778 | `IGitService`、`IGitCheckpointService` | Git 操作与检查点 |
| `file/`、`fileWatcher/`、`media-preview/`、`terminal/`、`system/` | 4129 | 5 个 | 文件读写、目录监视、媒体预览、终端（node-pty）、系统信息 |
| `setting/`、`settings-sync/`、`credential/`、`broadcast/`、`onboarding/` | 4033 | 5 个 | 设置、凭据、跨窗口广播、引导记录 |
| `cua-permission-broker/`、`feedback/` 等六个小目录 | 4046 | 7 个 | Computer Use 权限、反馈工单、客户端配置与场景、附件预传、窗口 Host 聚合面 |

另有 `process/`（进程树回收）、`storage/`（设置页的存储管理，全仓唯一的受管模块）等不带描述符的目录，以及 2782 行的组装根 `node.ts`。

### 旧痕迹：从多家 Agent 到只剩一个

如今 Agent 提供方的枚举只有一个值 `glm`（`packages/shared/src/providers.ts:10`），类型注释写着“当前仅保留 glm”（`packages/shared/src/zcode-task-types-core.ts:34`）。“保留”背后是一段拆掉的历史，证据散在服务层和仓库里仅存的几个测试里：

- UI 包的测试断言任务元数据不再接受 `claude`、`codex`、`gemini`、`opencode` 四种提供方，旧设置项 `enabledBuiltinAgentCliProviders` 会被剥掉，“Claude ACP text”也不再被当成问卷答案（`packages/ui/test/nonCliAcpRetirement.test.ts:30`、`ui/test/nonCliAcpRetirement.test.ts:37`、`ui/test/nonCliAcpRetirement.test.ts:47`）；服务包的同名测试要求打开任务索引时不动旧的 `acp_session_id` 列（`packages/services/test/nonCliAcpRetirement.test.ts:67`）。
- 存储管理把 `v2/acp-auth`、`v2/acp-config`、`v2/acp-traffic-proxy` 等目录单独归类，并注明“agent/ 是 ACP 时代残留，当前代码无写入方”（`packages/services/src/storage/domain/storageCatalog.ts:93`、`storageCatalog.ts:104`、`storageCatalog.ts:169`）；桌面端导出日志时跳过这些“已退役”的 ACP 运行时目录（`packages/desktop/src/main/exportLogs.ts:747`）。
- Provider 配置迁移的注释说“清理第三方 ACP 不能移除这条升级路径”（`packages/services/src/node.ts:1550`）；Claude Code 会话导入的注释说“legacy ACP 下线后”原入口成了空桩，现在由原生实现接手（`packages/services/src/zcode-agent/zcodeTaskServiceAdapter.ts:2606`）。

从这些残留推断，ZCode 早先经 ACP 接入 Claude、Codex、Gemini、OpenCode 这类第三方 Agent，后来统一换成自家的 Agent CLI 加 ZCode Protocol。仓库里没有记录这次迁移的文档。

## 同一套 UI，两种宿主

`packages/ui/src` 有 1475 个文件、32.2 万行，是全仓最大的包：

| 目录 | 文件 | 行数 | 内容 |
| --- | --- | --- | --- |
| `settings/` | 215 | 54122 | 设置页，Provider 与模型配置的 `model-provider-section/` 占 56 个文件 |
| `v4/` | 203 | 51917 | 基于 ZCode Protocol V4 的会话界面：时间线、输入框（`composer/`）、投影 store、传输层 |
| 根目录文件 | 134 | 43211 | `Root.tsx`、`App.tsx` 与一批面板、对话框 |
| `lib/` | 237 | 31088 | 附件、遥测、工具身份、任务列表等辅助逻辑 |
| `components/` | 150 | 28713 | shadcn/ui 基础组件（`ui/`）、Vercel ai-elements（`ai-elements/`）、工作流时间线与图 |
| `hooks/` | 98 | 16345 | 访问服务与平台能力的 hooks |
| `ToolCallBlocks/` | 74 | 15224 | 工具调用卡片 |
| `app-shell/` | 68 | 15163 | 侧边面板：子 Agent、工作流运行、后台任务输出等 |
| `i18n/` | 5 | 13087 | 中英文文案与 `IntlProvider` |
| `store/` | 42 | 12605 | Zustand 状态 |
| 其余 27 个目录 | 249 | 40999 | 文件树、提及、终端、引导、反馈等 |

UI 不知道自己跑在哪里。宿主交给 `Root` 三样东西：一个 `IServiceAccessor`、一个 `IPlatformService`，以及一组能力开关（`packages/ui/src/root/types.ts:6`）；`Root` 在最外层用 `ServiceProvider` 与 `PlatformProvider` 两个 Context 把前两样注入下去（`packages/ui/src/Root.tsx:118`）。`IPlatformService` 的注释定下了分工：只放“必须穿越进程边界且不适合做成 RPC service”的操作，比如原生对话框、窗口生命周期；文件、终端、凭据这些业务服务走 RPC（`packages/shared/src/platform.ts:518`）。这个接口有 116 个成员，其中 69 个可选（`platform.ts:522`）。

```mermaid
flowchart TB
  subgraph DH["桌面 Renderer"]
    D1["connectViaMessagePort"]
    D2["createDesktopPlatform<br/>转发 window.zcode"]
  end
  subgraph WH["浏览器"]
    W1["connectViaWebSocket"]
    W2["createWebPlatform<br/>空实现与浏览器 API"]
  end
  D1 -->|"IServiceAccessor"| ROOT["Root<br/>ServiceProvider 与 PlatformProvider"]
  W1 -->|"IServiceAccessor"| ROOT
  D2 -->|"IPlatformService"| ROOT
  W2 -->|"IPlatformService"| ROOT
  ROOT --> HK["hooks<br/>useServices、usePlatform、useWorkspaceServices"]
  HK --> C["组件、v4 会话界面、ToolCallBlocks"]
```

两种宿主的差别集中在这几处（浏览器一列的 `main.tsx` 指 `packages/web/src/main.tsx`）：

| | 桌面 Renderer | 浏览器 |
| --- | --- | --- |
| 服务 | `connectViaMessagePort`（`packages/desktop/src/renderer/src/main.tsx:305`） | 先读 `/api/server-info`，再 `connectViaWebSocket`（`renderer/src/main.tsx:358`） |
| 平台 | `createDesktopPlatform`，基本是对 `window.zcode` 的转发（`packages/desktop/src/renderer/src/desktopPlatform.ts:6`） | `createWebPlatform`，多数成员是空实现（`renderer/src/main.tsx:190`） |
| 选目录 | 系统对话框 | 返回 `null`，改用服务端目录浏览器（`preferDirectoryBrowser`） |
| 远程工作区 | 支持 | `connectRemote` 直接返回不支持，并传 `allowRemoteWorkspace={false}`（`renderer/src/main.tsx:208`、`web/src/main.tsx:467`） |
| 任务完成通知 | 系统通知 | 页面失焦且已授权时用浏览器的 `Notification`（`renderer/src/main.tsx:265`） |
| 内嵌浏览器、自动更新、导出日志 | 支持 | 关闭或空实现（`renderer/src/main.tsx:300`、`web/src/main.tsx:466`） |

Web 端不支持远程工作区的原因写在注释里：远程 WebSocket 只暴露文件、Git、系统、终端四个服务，与 `Root` 需要的完整 accessor 对不上（`packages/web/src/main.tsx:205`），所以后端的 `/api/connect-remote` 与 `/ws/remote/:id` 暂时没有前端在用。空实现里还留着一条注释，看得出这份接口怎样约束宿主：`IPlatformService` 新增更新提示能力后，“Web fallback 没有同步补齐空实现，根级 typecheck 会直接失败”（`web/src/main.tsx:316`）。

工作区一级还有第二层注入。一个窗口能同时开本地与远程工作区，`useWorkspaceServices` 按工作区挑 accessor：本地用基础服务，远程用对应远程会话的服务，远程会话不在时换成“断连代理”，所有调用直接以断连错误失败、事件一律空订阅（`packages/ui/src/hooks/useWorkspaceServices.tsx:23`、`useWorkspaceServices.tsx:54`）。断连代理也用 `ProxyChannel.toService` 生成，复用真实代理对方法与事件的分类，注释记下了两套规则漂移后出过的崩溃（`useWorkspaceServices.tsx:35`）。

根 AGENTS.md 的“UI 与平台边界”一节把这些写成了约定：组件通过 `packages/ui/src/hooks/` 访问服务，平台操作通过 `IPlatformService`，不直接调用 `window.zcode`，Desktop、Web、本地与远程的差异靠依赖注入处理（`AGENTS.md:52`、`AGENTS.md:53`）。执行得不错：`packages/ui` 里 `window.zcode` 只出现在注释里，唯一的代码访问是日志模块把日志转给桌面端（`packages/ui/src/logger.ts:58`）；`useServices()` 本身就是 `hooks/` 里的一个 hook，全包 56 个文件调用它，30 个在 `hooks/` 内，其余 26 个是组件直接取 accessor，其中设置页占 11 个。

### Zustand 与“服务端事实”

Zustand 用在 32 个文件里，29 个在 `store/`。全局 store 管主题、语言、界面字号、界面模式等偏好，这四个字段要跨窗口同步（`store/index.ts:214`）。同步走 `IBroadcastService`：桌面端经 Host 与 Main 的 BroadcastHub 转给其他窗口（`packages/services/src/broadcast/broadcast.ts:34`）；Web 服务端没有 `parentPort`，`send` 只触发本进程的 Emitter，连到同一后端的所有标签页都会收到，发送方自己也会收到回声（`packages/services/src/broadcast/broadcastService.ts:243`）。防回环靠一个标志位：订阅回调开头先看 `applyingBroadcast`，为真就直接返回，否则只比较这四个字段、变了才发 `state:` 前缀的广播（`store/index.ts:438`）；收到广播时先把标志置为 `true` 再调对应的 setter（`store/index.ts:470`），setter 照常写 localStorage、改 DOM，这次变更却不会再广播出去。AGENTS.md 说的“广播同步的主题、语言等字段需要防止回环”（`AGENTS.md:54`）就是这几行。

同一句的后半是“UI 局部状态不应被误当作服务端事实”。最典型的是会话：会话投影没有放进 Zustand，而是一个手写的、兼容 `useSyncExternalStore` 的类（`packages/ui/src/v4/conversationProjectionStore.ts:278`），文件头把规矩写死了（`conversationProjectionStore.ts:2`）：

```ts
// Per-session projection store（只读 projection store）。
// 唯一写入方是订阅推送；UI 只读。客户端遵守三条规则：
//   1. snapshot → 整体替换，绝不 merge；
//   2. delta 帧仅在区间衔接（frame.fromSeq === snapshot.seq）时 apply，断档不猜、不缓存补偿；
//   3. base 与状态同生共死——断档时状态未被污染，携当前水位重订阅，由服务端裁决 resume/snapshot。
// 除 optimistic overlay（pending 命令展示）外，本 store 不产生任何 conversation 事实。
```

UI 自己拥有的是草稿、选中的任务、面板布局这类状态；已受理的输入由 CLI 的 `CommandInbox` 串行受理，Renderer 只保留“未提交草稿与 pending optimistic overlay”（`AGENTS.md:65`）。

### 会话界面、工具卡片与文案

`v4/` 里每个 RPC 连接先做一次 `hello` 与 `clientHello` 握手，客户端类型按服务端报来的连接模式定：`desktop-continuous` 报 `desktop`，否则报 `web`（`packages/ui/src/v4/agentV4ConnectionHandshake.ts:32`）；之后按工作区建传输、订阅帧、交给投影 store。消息里的工具调用交给 `ToolCallBlocks`：`resolveToolCallRenderer` 先认改动组、命令组、Computer Use 组与一批按名字分流的工作流工具，再按工具身份的 family 挑卡片，一共 35 种渲染器，认不出的落到 `FallbackToolCallBlock`（`packages/ui/src/ToolCallBlocks/resolveRenderer.ts:57`）。

文案是两张扁平的键值表：`zh-CN` 5594 个键，`en-US` 5595 个（多出的一个没有被引用），查不到的键直接显示键名（`packages/ui/src/i18n/IntlProvider.tsx:118`），切换语言同样走 `state:locale` 广播（`IntlProvider.tsx:34`）。日志按 AGENTS.md 的“日志”一节分两套：UI 用 `logger.ts`，生产构建下普通日志全部关闭，只留筛选过的生命周期诊断（`logger.ts:40`、`logger.ts:62`）；服务层用 `createServiceLogger(scope)`，`debug` 只在本地开发运行时打印（`packages/services/src/logger/serviceLogger.ts:44`，规矩见 `AGENTS.md:77`）。前面提到的 Web 服务端 RPC 日志是个例外，它直接用 `console.log`；独立 Server Core 里同一个中间件用的是 `debug` 级（`server-core/http.ts:96`）。

## packages/web 与 packages/shared

`packages/web/src` 只有 17 个文件，`main.tsx` 按路径分出三种页面（`packages/web/src/main.tsx:424`）：`/share/callback` 与 `/cn/share/callback` 且带 `state` 等参数的是 OAuth 回调页（`web/src/main.tsx:90`）；以 `/share` 或 `/cn/share` 开头的是会话分享落地页，直接用 fetch 访问产品服务的 `/api/v1`，不连本地后端（`web/src/main.tsx:117`）；其余都是工作台，连 `/ws`，URL 带 `?remote=` 时改连 `/ws/remote/:id`。

`auth/` 是浏览器里的智谱登录，支持 z.ai 与 bigmodel：跳转前把随机 nonce 存进 sessionStorage，回调时比对，不一致就报“OAuth CSRF 检测失败”（`packages/web/src/auth/webAuthService.ts:137`、`packages/web/src/auth/browserOAuthCredentialRepo.ts:181`）；令牌交换发往相对路径 `/api/v1/oauth/token`（`packages/web/src/auth/webZaiOAuthConfig.ts:56`），开发时靠 Vite 那条单独的代理。这套登录只服务分享页，工作台的登录走服务端的 `IOAuthService`，见[账号、Coding Plan 与闲时计划](https://daiw.org/manual/zcode/accounts-plans)。

`packages/shared` 是两侧共用的契约层，只依赖 zod 与 `model-option-map`（`packages/shared/package.json:32`）。代表性的文件：

| 文件 | 行数 | 内容 |
| --- | --- | --- |
| `channels.ts` | 1149 | 40 个 RPC 服务频道、113 个 Electron IPC 频道、Main 与 Host 之间的消息类型 |
| `platform.ts` | 970 | `IPlatformService` 及其参数类型 |
| `validation.ts`、`validationAppSettings.ts` | 1252、562 | zod 运行时校验，例如远程目标与 stdio 握手确认（`packages/shared/src/validation.ts:96`、`validation.ts:110`） |
| `zcode-protocol/index.ts`、`zcode-protocol-v4/` | 3717、8927 | Agent 线协议的类型与校验，见 [ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol) |
| `zcode-task-types-core.ts` | 1187 | 任务、会话、权限请求等领域类型 |
| `server-remote.ts` | 37 | `/api/server-info` 的结构与协议版本 |

## 设计规范与架构规则

`DESIGN.md` 是写给编码 Agent 看的 UI 规范（`DESIGN.md:5`），要点有四条：

- **字号**是最高优先级的约束：界面文字只许用 `text-ui-xl` 到 `text-ui-xs` 这套刻度，不许用 Tailwind 自带的 `text-sm` 一类，也不许写任意像素值（`DESIGN.md:11`）；缩放字号只改 `--ui-font-size`（默认 14px），不碰 `html` 的字号（`DESIGN.md:16`、`DESIGN.md:190`）。
- **气质**：桌面优先、兼容 Web，安静、密集、偏操作（`DESIGN.md:22`）；对外的主题只有跟随系统、浅色（Zai Light）与深色（Zai Dark）三种（`DESIGN.md:47`）。
- **形状**：圆角按嵌套逐级递减，第一层 `rounded-xl`（`DESIGN.md:290`）；间距以 4px 为单位（`DESIGN.md:265`）；阴影克制，浮层用 `shadow-md`（`DESIGN.md:469`）。
- **响应式**只改布局、宽度与密度，不改组件语义（`DESIGN.md:497`）；手机远控保留单列加抽屉的形态（`DESIGN.md:487`）。

架构规则方面，[仓库全景](https://daiw.org/manual/zcode/monorepo-map)已经讲过：`managedOnly` 让检查器只看受管模块，而 15 个模块里只有 `storage` 受管。落到本篇的几个包上更具体一些：

- `server`、`zcode-server-cli`、`rpc`、`client`、`web`、`ui`、`services`、`shared` 全是 `managed: false`（`architecture-policy.yaml:4`），单文件行数、循环依赖、深层导入这些规则一条都不作用于它们。
- 检查器里有一条专为 UI 写的 `ui-implementation-import`：`ui` 模块的文件若导入了 `repo`、`runtime`、`service(s)` 目录下的实现就报错（`scripts/architecture/index.mjs:226`），这是 AGENTS.md“禁止 UI 直接调用 Repo”（`AGENTS.md:47`）的可执行版本。但它排在“跳过非受管文件”（`index.mjs:132`）之后，今天不会触发；就算 `ui` 受管，解析器也只跟相对路径的 import（`scripts/architecture/policy.mjs:153`），而 UI 代码几乎都用 `@/` 别名和 `@zcode/*` 包名。
- 实际生效的是 oxlint 的 `max-lines` 400（`.oxlintrc.json:6`）：`packages/ui/src` 有 139 个文件、`packages/services/src` 有 46 个在文件头写明理由后关掉了它，`packages/rpc/src` 与 `packages/client/src` 一个都没有。

下一篇：[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)——远程资源怎样部署到 SSH、WSL 与 Docker 远端，`workspaceIdentity` 与 `remoteSessionId` 怎样一路贯穿，手机又怎样连到桌面已有的 Host。
