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

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

作者 David更新于 45 篇(共 47 篇)

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

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

两种起法

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

pnpm dev:webzcode --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.htmlpackages/server/src/entry-http.ts:24
监听地址不指定(见下方提示)默认 127.0.0.1runner.mjs:37
端口PORT,缺省 3030entry-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

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

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

路由与令牌

后端是一个 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-remoteGET /ws/remote/:id由后端发起 SSH 等远程连接,再把远端的文件、Git、系统、终端四个服务桥接给浏览器(src/http.ts:346src/http.ts:368
GET *静态文件;index.htmlno-cache,其余一年 immutablesrc/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= 进来,命中后服务端顺手种一个 HttpOnlySameSite=Lax 的 Cookie,之后的同源请求靠它通过(src/http.ts:224),所以终端给的是带令牌的首页链接,页面连 WebSocket 时不必再带。这个 Cookie 叫 zcode_lite_tokensrc/http.ts:184)——命令行版的前身叫 Lite,README 还专门提醒“旧 Lite 用户需要改用上述构建命令、环境变量和新的安装脚本”(README.md:187)。

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

三个宿主入口

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

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

stdio 入口先在 stdout 发一行 zcode-hello,10 秒内等不到 hello-ack 就失败(packages/server/src/entry-stdio.ts:41entry-stdio.ts:92);stdout 只能承载 RPC 帧,所以它把 console.loginfowarndebug 一律改道 stderr(entry-stdio.ts:24);退出分“停 RPC”和“回收服务”两段,各自限时 1 秒与 3.5 秒,保证 SSH 断开时 Agent 进程树也能被清理(packages/server/src/stdio-lifecycle.ts:4stdio-lifecycle.ts:78)。独立 Server 的 CLI 提供 servestatusstoprestartupdateuninstallpackages/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:128verify-remote-ssh.mjs:4)。这个包不进命令行发行包(scripts/build-zcode.mjs:177)。远端部署、守护与升级见远程工作区与手机远控

一条 WebSocket 连接

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

  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 记账订阅,要求终端客户端先完成 helloclientHello 握手(packages/services/src/zcode-agent/zcodeAgentConnectionScope.ts:228zcodeAgentConnectionScope.ts:656),命令里的 clientId 与握手绑定的不一致就拒绝(zcodeAgentConnectionScope.ts:684),流控这类接口只许可信中继调用(zcodeAgentConnectionScope.ts:666)。省略的那段把 Provider Provisioning 对非 desktop-continuous 连接换成一个只会抛错的桩,注释的理由是它“携带跨 Environment 凭据,只允许 Desktop trusted host 使用”(src/http.ts:105)。
  • 连接关闭时释放作用域与 ChannelServersrc/http.ts:118)。作用域只退订自己名下的订阅(zcodeAgentConnectionScope.ts:1015),Agent 进程不受影响,刷新页面、重新订阅就能接上。
  • 桌面 Host 的 MessagePort 连接是同一套写法,多挂了网络遥测中间件,替换的频道也更多(packages/desktop/src/host/index.ts:1978host/index.ts:1990)。
图表加载中…

Agent 进程由 ZCodeAgentProcessManager 按工作区键管理,一个工作区一个进程,命令解析链的细节见桌面应用。与 Web 模式有关的只有三点:zcode --webZCODE_AGENT_SERVER_COMMANDZCODE_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:356zcodeAgentProcessManager.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:2050zcodeAgentService.ts:2088);连接作用域只把本连接订阅过的帧转下去(zcodeAgentConnectionScope.ts:830);浏览器里的会话传输层订阅动态事件 onDynamicConversationFramepackages/ui/src/v4/agentConversationTransport.ts:546),落到 RPC 上就是一次 EventListen 请求加源源不断的 EventFire 响应。帧的语义、快照与重放见 ZCode Protocol V4

RPC:从 VS Code 搬来的 IPC 骨架

@zcode/rpc 的自我描述是“VS Code style IPC communication abstraction framework”(packages/rpc/package.json:4)。这不只是风格上的借鉴:third-party/copied-components.jsonpackages/rpc/srcpackages/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 基础设施EventEmitterDisposableStoreCancellationTokenVSBuffer用,Emitter 遍布各包
1 序列化serializedeserialize
2 传输SocketProtocolMessagePortProtocolPersistentProtocol前两个用,PersistentProtocol 没有
3 Channel RPCChannelServerChannelClient
4 连接管理IPCServerIPCClientStaticRouter没有
5 服务代理ProxyChannel.fromServicetoService
6 远程连接RemoteAgentConnection没有
中间件LoggingChannelServerNetworkTelemetryChannelServer用;两个 Client 侧版本没有

没人用的 ipc.tsremote.tspersistent-protocol.ts 合计 964 行,约占包的三成。

线上格式

两层编码。外层是 13 字节帧头:类型 1 字节,序号、确认号、长度各 4 字节,大端序(packages/rpc/src/protocol.ts:184)。SocketProtocol 只分帧,序号与确认号都填 0(protocol.ts:253);WebSocket 本身有消息边界,但它和 stdio 共用这个类,所以也带着帧头。桌面的 MessagePortProtocol 不分帧,直接投递 Uint8Arrayprotocol.ts:361)。内层是带类型标签的序列化:每个值前面 1 字节类型,undefined、字符串、BufferVSBuffer、数组、对象、整数七种,长度用 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:29packages/rpc/src/channelClient.ts:123);桌面 Host 连远程工作区时用 deferInit 把这一步推迟到远端服务就绪,否则 Renderer 立刻发出的请求会撞上“Unknown channel”(host/index.ts:1974)。
  • 请求到达时频道还没注册,服务端先挂起,1000 毫秒后仍未注册就回 Unknown channel 错误(channelServer.ts:25channelServer.ts:229)。客户端的代理对频道存不存在一无所知,缺失的服务只会在调用时以这种方式暴露。
  • 错误跨进程时保留 messagenamestack,另透传 codekindstatusretryAfterMsdatadetaildetailstaskIdtraceId 九个字段(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,并让 Symbolthen 走普通对象语义,免得 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:77persistent-protocol.ts:81persistent-protocol.ts:92)。注释里满是 v4 通道与“对端锁屏”的考虑,但仓库里没有生产代码用它。Web 模式走的是不带 ACK 的 SocketProtocol,传给 connectViaWebSocketonClose 是个空函数(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 只有 sendonMessage,“只要实现 send() 和 onMessage,就能接入整个 RPC 框架”(protocol.ts:8)。ZCode 恰好有三种传输——桌面的 MessagePort、Web 的 WebSocket、远端的 stdio——每种只要一个四十行左右的 socket 包装(packages/server/src/http.ts:41stdio.ts:14packages/client/src/websocket.ts:23),上面的 Channel、代理与服务集合一行不改;ProxyChannel 又省掉了为每个服务手写分发的样板。对照 OpenCode 的 HTTP 服务端:OpenCode 让所有前端走同一套 HTTP API,ZCode 的 Web 模式只用 HTTP 做引导,业务全在 WebSocket 上的二进制 RPC 里。

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

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

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 侧的组装根是 createLocalServicespackages/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 复用这个工厂,再补上它独有的 IWindowControllerServicehost/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/servicespackages/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/19592IZCodeAgentService(116 个成员,packages/services/src/zcode-agent/zcodeAgent.ts:566Agent 子进程与 ZCode Protocol 客户端、v4 帧分发、连接作用域
session/zcode-session/12210IZCodeTaskServiceIOffPeakTaskServiceIZCodeSessionService任务包装与任务索引、自动化与闲时任务、会话应用服务
skills/plugins/subagents/commands/hooks/memory/ 与三个 *-sync/872910 个扩展与记忆的管理,以及向远端同步
oauth/usage-stats/coding-plan-subscription/79623 个登录、用量统计、套餐购买
model-provider/providers/6034IProviderSettingsServiceIModelSelectionServiceIProviderProvisioningTargetServiceProvider 配置视图、模型选择、向远端 Environment 下发配置
conversation-share/5482IConversationShareService会话分享的发布、预览与续聊
git/4778IGitServiceIGitCheckpointServiceGit 操作与检查点
file/fileWatcher/media-preview/terminal/system/41295 个文件读写、目录监视、媒体预览、终端(node-pty)、系统信息
setting/settings-sync/credential/broadcast/onboarding/40335 个设置、凭据、跨窗口广播、引导记录
cua-permission-broker/feedback/ 等六个小目录40467 个Computer Use 权限、反馈工单、客户端配置与场景、附件预传、窗口 Host 聚合面

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

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

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

  • UI 包的测试断言任务元数据不再接受 claudecodexgeminiopencode 四种提供方,旧设置项 enabledBuiltinAgentCliProviders 会被剥掉,“Claude ACP text”也不再被当成问卷答案(packages/ui/test/nonCliAcpRetirement.test.ts:30ui/test/nonCliAcpRetirement.test.ts:37ui/test/nonCliAcpRetirement.test.ts:47);服务包的同名测试要求打开任务索引时不动旧的 acp_session_id 列(packages/services/test/nonCliAcpRetirement.test.ts:67)。
  • 存储管理把 v2/acp-authv2/acp-configv2/acp-traffic-proxy 等目录单独归类,并注明“agent/ 是 ACP 时代残留,当前代码无写入方”(packages/services/src/storage/domain/storageCatalog.ts:93storageCatalog.ts:104storageCatalog.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/21554122设置页,Provider 与模型配置的 model-provider-section/ 占 56 个文件
v4/20351917基于 ZCode Protocol V4 的会话界面:时间线、输入框(composer/)、投影 store、传输层
根目录文件13443211Root.tsxApp.tsx 与一批面板、对话框
lib/23731088附件、遥测、工具身份、任务列表等辅助逻辑
components/15028713shadcn/ui 基础组件(ui/)、Vercel ai-elements(ai-elements/)、工作流时间线与图
hooks/9816345访问服务与平台能力的 hooks
ToolCallBlocks/7415224工具调用卡片
app-shell/6815163侧边面板:子 Agent、工作流运行、后台任务输出等
i18n/513087中英文文案与 IntlProvider
store/4212605Zustand 状态
其余 27 个目录24940999文件树、提及、终端、引导、反馈等

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

图表加载中…

两种宿主的差别集中在这几处(浏览器一列的 main.tsxpackages/web/src/main.tsx):

桌面 Renderer浏览器
服务connectViaMessagePortpackages/desktop/src/renderer/src/main.tsx:305先读 /api/server-info,再 connectViaWebSocketrenderer/src/main.tsx:358
平台createDesktopPlatform,基本是对 window.zcode 的转发(packages/desktop/src/renderer/src/desktopPlatform.ts:6createWebPlatform,多数成员是空实现(renderer/src/main.tsx:190
选目录系统对话框返回 null,改用服务端目录浏览器(preferDirectoryBrowser
远程工作区支持connectRemote 直接返回不支持,并传 allowRemoteWorkspace={false}renderer/src/main.tsx:208web/src/main.tsx:467
任务完成通知系统通知页面失焦且已授权时用浏览器的 Notificationrenderer/src/main.tsx:265
内嵌浏览器、自动更新、导出日志支持关闭或空实现(renderer/src/main.tsx:300web/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:23useWorkspaceServices.tsx:54)。断连代理也用 ProxyChannel.toService 生成,复用真实代理对方法与事件的分类,注释记下了两套规则漂移后出过的崩溃(useWorkspaceServices.tsx:35)。

根 AGENTS.md 的“UI 与平台边界”一节把这些写成了约定:组件通过 packages/ui/src/hooks/ 访问服务,平台操作通过 IPlatformService,不直接调用 window.zcode,Desktop、Web、本地与远程的差异靠依赖注入处理(AGENTS.md:52AGENTS.md:53)。执行得不错:packages/uiwindow.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 服务端没有 parentPortsend 只触发本进程的 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):

// 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 连接先做一次 helloclientHello 握手,客户端类型按服务端报来的连接模式定:desktop-continuousdesktop,否则报 webpackages/ui/src/v4/agentV4ConnectionHandshake.ts:32);之后按工作区建传输、订阅帧、交给投影 store。消息里的工具调用交给 ToolCallBlocksresolveToolCallRenderer 先认改动组、命令组、Computer Use 组与一批按名字分流的工作流工具,再按工具身份的 family 挑卡片,一共 35 种渲染器,认不出的落到 FallbackToolCallBlockpackages/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:40logger.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:137packages/web/src/auth/browserOAuthCredentialRepo.ts:181);令牌交换发往相对路径 /api/v1/oauth/tokenpackages/web/src/auth/webZaiOAuthConfig.ts:56),开发时靠 Vite 那条单独的代理。这套登录只服务分享页,工作台的登录走服务端的 IOAuthService,见账号、Coding Plan 与闲时计划

packages/shared 是两侧共用的契约层,只依赖 zod 与 model-option-mappackages/shared/package.json:32)。代表性的文件:

文件行数内容
channels.ts114940 个 RPC 服务频道、113 个 Electron IPC 频道、Main 与 Host 之间的消息类型
platform.ts970IPlatformService 及其参数类型
validation.tsvalidationAppSettings.ts1252、562zod 运行时校验,例如远程目标与 stdio 握手确认(packages/shared/src/validation.ts:96validation.ts:110
zcode-protocol/index.tszcode-protocol-v4/3717、8927Agent 线协议的类型与校验,见 ZCode Protocol V4
zcode-task-types-core.ts1187任务、会话、权限请求等领域类型
server-remote.ts37/api/server-info 的结构与协议版本

设计规范与架构规则

DESIGN.md 是写给编码 Agent 看的 UI 规范(DESIGN.md:5),要点有四条:

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

架构规则方面,仓库全景已经讲过:managedOnly 让检查器只看受管模块,而 15 个模块里只有 storage 受管。落到本篇的几个包上更具体一些:

  • serverzcode-server-clirpcclientwebuiservicesshared 全是 managed: falsearchitecture-policy.yaml:4),单文件行数、循环依赖、深层导入这些规则一条都不作用于它们。
  • 检查器里有一条专为 UI 写的 ui-implementation-importui 模块的文件若导入了 reporuntimeservice(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/srcpackages/client/src 一个都没有。

下一篇:远程工作区与手机远控——远程资源怎样部署到 SSH、WSL 与 Docker 远端,workspaceIdentityremoteSessionId 怎样一路贯穿,手机又怎样连到桌面已有的 Host。

本页目录