Web 与服务端:同一套 UI 的另一种宿主
zcode --web 与 pnpm dev:web 各起了什么、令牌怎样生效;Hono 服务怎样经仿 VS Code 的 RPC 把业务服务交给浏览器、拉起 Agent 并推回事件;同一套 React UI 如何靠依赖注入同时跑在 Electron 与浏览器里,架构规则又管到了哪里。
桌面端之外,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/server、packages/zcode-server-cli、packages/rpc、packages/client、packages/web 以及上面三个大包里,各包规模见仓库全景;packages/server/src 的 48 个文件里有 38 个在 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:
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时每次启动都换一个源,这些偏好不会延续。
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。
路由与令牌
后端是一个 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)。远端部署、守护与升级见远程工作区与手机远控。
一条 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 记账订阅,要求终端客户端先完成
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)。
Agent 进程由 ZCodeAgentProcessManager 按工作区键管理,一个工作区一个进程,命令解析链的细节见桌面应用。与 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。
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 服务端: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):
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)。
两种宿主的差别集中在这几处(浏览器一列的 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):
// 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 与闲时计划。
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 |
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)。
架构规则方面,仓库全景已经讲过: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-lines400(.oxlintrc.json:6):packages/ui/src有 139 个文件、packages/services/src有 46 个在文件头写明理由后关掉了它,packages/rpc/src与packages/client/src一个都没有。
下一篇:远程工作区与手机远控——远程资源怎样部署到 SSH、WSL 与 Docker 远端,workspaceIdentity 与 remoteSessionId 怎样一路贯穿,手机又怎样连到桌面已有的 Host。