桌面应用:Electron 的三层
ZCode 桌面端怎样把 Electron 拆成 Main、窗口级 Local Host 与 Renderer:Host 用 Electron 自带的 Node 拉起 app-server 模式的 Agent 子进程并管理其生命周期,任务列表怎样与 Agent 会话库同步;另有 V8 字节码试验、内嵌浏览器、自动更新、终端、Claude Code 历史导入与打包签名。
ZCode 的桌面端是一个 Electron 41 应用(packages/desktop/package.json:76)。packages/desktop/src 的非测试代码约 6 万行:Electron 主进程 main 占 4.7 万行,其中内嵌浏览器 main/browserView 一处就有 1.1 万行;窗口宿主 host 约 1 万行;preload 与 renderer 加起来不到 2600 行——界面来自共享的 @zcode/ui,Renderer 只是一层壳。业务服务在 packages/services,Agent 是独立的子进程,两者之间走 stdio 上的 ZCode Protocol。
这一篇讲这些进程怎样分工、谁拉起谁、状态归谁所有。协议本身见ZCode Protocol V4,远程工作区与手机远控留给远程工作区与手机远控。
| 位置 | 职责 |
|---|---|
packages/desktop/src/main | Electron 主进程:窗口、菜单与托盘、深链、自动更新、内嵌浏览器、跨窗口广播,拉起 Host 与 Scheduler |
packages/desktop/src/host | 每个窗口一个的 utility process:装配 @zcode/services,经 MessagePort 暴露 RPC,拉起 Agent,持有远程连接 |
packages/desktop/src/preload、renderer | window.zcode 桥与端口转交;接端口、建 RPC 客户端、挂上 @zcode/ui |
packages/desktop/src/scheduler | 常驻的定时与闲时任务调度进程 |
packages/services/src/zcode-agent | Agent 子进程管理、协议客户端、stdio 传输、任务索引同步 |
packages/services/src/zcode-session | desktop-continuous 链路上的会话操作 |
进程与分工
AGENTS.md 给 Main 划的边界是(AGENTS.md:60):
Main 负责窗口、原生操作、进程调度和消息转发,不承载 task/session 业务状态。
照这个边界看各进程:
- Main。
app.whenReady里先读设置、应用自定义数据目录,等本地数据库准备好才拉起 Scheduler(packages/desktop/src/main/index.ts:1893),自动更新只在正式版身份下启用(main/index.ts:1945)。它并非完全无状态:TaskRealtimeBus维护跨 Host 的运行租约、流镜像批次与回放缓冲(最多 60 批、512 KB,packages/desktop/src/main/taskRealtimeBus.ts:25),BroadcastHub把一个 Host 发来的广播转给其他窗口的 Host(packages/desktop/src/main/broadcastHub.ts:12)。这些是路由与有界回放用的状态,任务与会话的权威数据不在这里。 - preload。只暴露必须由 Main 参与的平台操作,注释特意说明凭据已迁到 Host 的
ICredentialService(packages/desktop/src/preload/index.ts:240)。MessagePort 过contextBridge会被包成 Proxy、丢掉原生方法,所以改用window.postMessage的 transfer 原样交给页面(preload/index.ts:815)。 - Renderer。收到端口后
connectViaMessagePort建ChannelClient,把RemoteServiceAccess交给@zcode/ui的Root(packages/desktop/src/renderer/src/main.tsx:303、packages/client/src/messageport.ts:25)。组件只经IPlatformService做平台操作(AGENTS.md:52),接口注释说它只放“必须穿越进程边界且不适合做成 RPC service”的操作,文件、终端、凭据等业务服务走 RPC(packages/shared/src/platform.ts:518);桌面实现就是对window.zcode的一层包装(packages/desktop/src/renderer/src/desktopPlatform.ts:6)。 - Scheduler。Main 以服务名
zcode-cron-schedulerfork 出来(packages/desktop/src/main/desktopCronScheduler.ts:61),每 20 秒轮询一次tasks-index,错过超过 5 分钟的触发记为跳过(packages/desktop/src/scheduler/index.ts:33、scheduler/index.ts:38);它只读写任务索引,到期任务经 Main 交给 Host 执行(scheduler/index.ts:1)。细节见定时任务与闲时任务。
每个窗口一个 Local Host
Host 入口文件开头的注释把拓扑画得很直白(packages/desktop/src/host/index.ts:3):
/**
* Host Process 入口 —— 每个窗口对应一个独立的 host process
*
* 同一窗口的 Renderer 和手机 都 attachment 到这个 Host:
* Renderer / Mobile ←MessagePort→ Window Host
* ├─ local services
* └─ remote connection registry
*
* 启动流程:
* 1. main 进程通过 Electron `utilityProcess.fork()` 创建本进程
* 2. main 进程只发送一次 init-local 初始化窗口 Host
* 3. 后续远端 connect / scoped attachment 都由同一 Host 处理
*/窗口的 dom-ready 触发时,Main 为它 fork 一个 Host,标签是 local- 加 webContents id(packages/desktop/src/main/desktopWindowLifecycle.ts:89、desktopWindowLifecycle.ts:193)。随 InitLocal 消息一起发出的是一对 MessageChannelMain:port2 给 Host,port1 投给 Renderer(packages/desktop/src/main/desktopHostProcess.ts:559)。
Host 的寿命属于窗口,不属于页面加载周期。Renderer 刷新时,Main 不杀旧 Host,而是给它补挂一条新端口,补挂失败才退回重建(desktopWindowLifecycle.ts:143):
if (oldChild && oldChild.pid !== undefined) {
try {
const startupPayload = getDatabaseStartupPortPayload(oldChild);
if (!startupPayload) throw new Error("Previous Host startup binding is unavailable");
const { port1, port2 } = new MessageChannelMain();
oldChild.postMessage(
{
type: HostMessageTypes.AttachServicePort,
requestId: randomUUID(),
attachmentId: randomUUID(),
clientMode: "desktop-continuous",
scope: { kind: "local" },
},
[port2],
);
win.webContents.postMessage(InternalChannels.ServicePort, startupPayload, [port1]);上方注释交代了原因:旧实现 reload 时连 Host 带 CLI 一起杀掉,运行中的会话直接消失(desktopWindowLifecycle.ts:138)。AttachServicePort 也是手机与远程工作区复用的通道:Host 里每条 attachment 都新建一个 ChannelServer 和一个带独立 connectionId、clientMode 的连接作用域,但底下共用同一个 ServiceCollection(packages/desktop/src/host/index.ts:1964、host/index.ts:2073)。所以一个窗口里所有本地工作区共享一个 Host、一套服务、一个 Agent 进程管理器(AGENTS.md:61)。
数据库准备先于服务。收到 InitLocal 后,Host 不马上装配服务,而是先跑数据库准备:在 Worker 里迁移 tasks-index.sqlite,再对每个预热目录用同一个 CLI bundle 的存储入口迁移会话库(packages/desktop/src/host/hostDatabaseStartup.ts:46、hostDatabaseStartup.ts:57)。后者也在 Worker 里运行,参数是 app-server --stdio --prepare-storage --cwd,30 秒内收不到第一帧状态就判超时(packages/desktop/src/host/storagePreparationProcesses.ts:130、storagePreparationProcesses.ts:151)。其间 Renderer 显示数据库启动页(packages/desktop/src/renderer/src/main.tsx:356),早到的 attachment 先挂起,准备就绪后再接入,不会启动第二个执行者(host/index.ts:2702)。就绪后才调用 createLocalServices,身份是 runtimeSurface: "desktop_local_host"、serviceAuthorityMode: "desktop-local"(host/index.ts:2815),并为 Main 挑出的最近 3 个工作区预热 Agent(packages/desktop/src/main/startupWorkspace.ts:40、host/index.ts:2881)。
关窗时 Main 给 Host 发 Dispose,至少等 3.5 秒才强杀,好让 Host 先回收 Agent 进程树;注释说若按 150 或 300 毫秒强杀,Host 先退出,app-server 子进程就可能被 init 接管成孤儿(desktopHostProcess.ts:644)。
拉起 Agent
Renderer 的调用经 MessagePort RPC 到达 Host 里的 zcodeAgentService,再由 ZCodeProtocolClient 写进 Agent 的 stdin;客户端可发的方法是旧版方法名与 v4/* 并存,注释说正在收敛到 v4(packages/services/src/zcode-agent/zcodeProtocolClient.ts:15)。Agent 的通知与事件沿原路回到 Renderer。
Agent 进程由 ZCodeAgentProcessManager 按工作区管理。命令来源有固定的先后(packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:438):
export function resolveDefaultZCodeAgentCommand(
context: ZCodeAgentCommandResolverContext,
): ZCodeAgentCommand | null {
const command = process.env.ZCODE_AGENT_SERVER_COMMAND?.trim();
if (command) {
return applyPresentationSurfaceToCommand(
{
command,
args: parseArgsJson(process.env.ZCODE_AGENT_SERVER_ARGS_JSON) ?? ["app-server", "--stdio"],
cwd: process.env.ZCODE_AGENT_SERVER_CWD?.trim() || context.workspacePath,
},
context.presentationSurface,
);
}
// 顺序:env 显式覆盖 → monorepo dev 源码/dist(dev 改源码立刻生效,不会被远端历史装的 native binary
// 抢先匹配)→ 桌面打包态 Electron Node runtime 跑 zcode.cjs → 已部署 native binary(远端 SSH 兜底)。
const bundled =
resolveBundledWorkspaceZCodeAgentCommand(context) ??
resolveElectronRuntimeZCodeAgentCommand(context);
return applyPresentationSurfaceToCommand(
bundled
? { ...bundled, supportsStorageStartup: true }
: resolveDeployedZCodeAgentBinaryCommand(context),
context.presentationSurface,
);
}- 打包态:Host 跑在 Electron utility process 里,
process.execPath指向 Electron Helper,于是直接用它加ELECTRON_RUN_AS_NODE=1执行resources/glm/zcode.cjs;注释说这样不必再随包带一份 Node,体积从约 180 MB 降到约 16 MB(zcodeAgentProcessManager.ts:412、zcodeAgentProcessManager.ts:415)。不设这个变量,子进程会被当成 Chromium 子进程卡在 GPU 初始化(zcodeAgentProcessManager.ts:433)。 - 开发态:向上找仓库里的
apps/zcode-cli/packages/cli/dist/zcode.cjs,找不到 dist 就用tsx直接跑src/main.ts(zcodeAgentProcessManager.ts:353、zcodeAgentProcessManager.ts:379)。 - 参数:默认
app-server --stdio(packages/shared/src/zcode-agent-runtime.ts:31)。Host 的装配事实是桌面本地宿主或挂在桌面上的远端、且灰度开关没有关掉时,再追加--surface desktop(packages/services/src/zcode-agent/zcodeAgentPresentationSurface.ts:12、zcodeAgentProcessManager.ts:466),CLI 把它解析为zcode_desktop呈现面(apps/zcode-cli/packages/cli/src/run.ts:146)。 - 环境:清洗后的
process.env,加运行环境标记、设置页的代理等 spawn 期变量、命令自带变量,再加ZCODE_WORKSPACE_IDENTITY;POSIX 下以独立进程组启动,便于整棵树回收(zcodeAgentProcessManager.ts:1019、packages/services/src/runtime-tools/agentProxyEnv.ts:116)。
进程池以工作区 key 为键,同一工作区的并发 getClient 收敛到同一个启动中的 Promise(zcodeAgentProcessManager.ts:839、zcodeAgentProcessManager.ts:859),一个 Agent 进程里跑该工作区的全部会话。会话之外,Host 还有两条控制面泳道,都以数据目录下固定的 .zcode/plugin-workspace 作工作目录(packages/services/src/zcode-agent/zcodeAgentService.ts:464):插件市场管理,请求超时放宽到 5 分钟(zcodeAgentService.ts:1076、zcodeAgentService.ts:325);查询 MCP 状态的 mcp-status,空闲 5 分钟自动回收,单独成进程是因为 mcp/list 的慢握手会堵住串行的 stdio 队列(zcodeAgentService.ts:1083、zcodeAgentService.ts:322)。
Agent 也会反过来向 Host 发请求,Host 处理的有:session/requestRuntimePreferences、interaction/requestPermission、interaction/requestUserInput、interaction/requestProviderRuntimeHeaders、interaction/requestOfficialMcpAuthHeaders、interaction/browserList 与 interaction/browserExecute、automation/create 与 automation/list、offPeak/create 与 offPeak/list(zcodeAgentService.ts:2103 起,方法名见 packages/shared/src/zcode-protocol/index.ts:3567、zcode-protocol/index.ts:3641、zcode-protocol/index.ts:3657)。
stdio 与 stderr
stdout 上一行一帧 JSON。分帧只认 LF,不用 readline,因为它会把 U+2028、U+2029 当成换行,把含这类字符的模型文本切成半帧(packages/services/src/zcode-agent/zcodeStdioTransport.ts:61)。每帧都过 zod 校验,解析失败直接把传输判为关闭(zcodeStdioTransport.ts:244)。
stderr 有自己的收集器,寿命独立于协议(packages/services/src/zcode-agent/agentStderrCollector.ts:6)。每行先脱敏(api_key=、Bearer、sk- 开头的裸 Key 等),截到 1000 字符,只保留最后 20 行(zcodeAgentProcessManager.ts:246);能解析成结构化诊断的行单独作为运行期异常上报(zcodeAgentProcessManager.ts:1038)。进程非预期退出时,先等 stderr 排空(默认 250 毫秒,agentStderrCollector.ts:4),再把尾部 20 行随退出事件写进错误日志(zcodeAgentProcessManager.ts:1239)。
超时、退出与“重启”
请求默认超时 3 分钟(packages/services/src/zcode-agent/zcodeProtocolClient.ts:48)。业务请求超时说明这条连接已不可信,管理器会淘汰这个客户端并回收整棵进程树,只有取消通知超时是例外(zcodeAgentProcessManager.ts:1280、zcodeAgentProcessManager.ts:1293)。
代码里没有“崩溃后自动重启”的循环。协议关闭或进程退出时,条目从进程池摘掉(zcodeAgentProcessManager.ts:1312),下一次 getClient 才拉起新一代;运行时身份是“工作区 key、代次、pid、泳道”拼成的字符串(zcodeAgentProcessManager.ts:1089)。新代次只有在收到真实的 spawn 事件后才广播“运行时已重启”,注释说此前在 spawn 失败时也广播,引发“失败启动、假重启、重连”的自激风暴(zcodeAgentProcessManager.ts:1137)。订阅方据此重订 v4 主题,因为订阅活在 CLI 进程内存里,换代即失效(zcodeAgentProcessManager.ts:202)。
正常关闭先给 stdin 发 EOF,这是 app-server --stdio 的自然退出边界;最多等 1.8 秒,再按预先保存的进程树快照回收后代,强杀前留 2 秒(zcodeStdioTransport.ts:27、zcodeStdioTransport.ts:152)。
存储启动门
开发态与打包态的内置 Agent 命令都标了 supportsStorageStartup(见上面代码里的 bundled 分支),远端部署的命令不标。这类 Agent 启动时先打开自己的 SQLite 会话库(需要时迁移),并用 startup/storageState 通知逐阶段报告(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:40);ZCodeStorageStartupGate 只接受同一次尝试、同一个库、序号递增的状态帧,30 秒内没有第一帧就以 startup_status_timeout 失败(packages/services/src/zcode-agent/zcodeStorageStartupGate.ts:9、zcodeStorageStartupGate.ts:38)。门没开之前,业务请求连请求对象和超时计时器都不创建(zcodeProtocolClient.ts:141);状态为失败时,所有挂起请求一并拒绝(zcodeProtocolClient.ts:311)。空闲回收也会避开迁移中的进程(zcodeAgentProcessManager.ts:641)。
任务列表与 Agent 会话库
会话的权威数据在 Agent 的 SQLite 会话库里(见SQLite 会话库)。侧栏的任务列表另有一个库 ~/.zcode/v2/tasks-index.sqlite(packages/services/src/paths.ts:185),存未读、分组、归档、全文检索文本这些“产品壳”状态,多窗口的 Host 共用它。两边靠 ZCodeTaskIndexSyncer 对齐:为每个工作区订阅 v4 的 sessions-index 与 workspace-config 两个主题,把 CLI 投影出的终态、标题与配置目录落到索引库,再广播 workspace_task_list_changed(packages/services/src/zcode-agent/zcodeTaskIndexSyncer.ts:94)。
每条会话摘要的处理规则集中在 processSummary(zcodeTaskIndexSyncer.ts:669):
// draft 裁决:纯内存态、不落盘,也绝不进 task index sqlite。
if (next.phase === "draft") {
return;
}
const target = sessionTargetFrom(state.target, next.sessionId);
const becameVisibleTask = previous === undefined || previous.phase === "draft";
// 终态迁移 = 基线里真实观察到非终态 → 终态。无基线的会话(冷恢复 hydration、
// 断档降级后新出现的历史会话)不回放终态;活跃会话必先以 running/prewarming
// 进入基线(gateway 每个事件都 fan-out),不会漏掉真实收口。
const becameTerminal =
previous !== undefined && !isTerminalPhase(previous.phase) && isTerminalPhase(next.phase);
if (becameTerminal) {
applyTerminalTransition(target, next, {
moveGroupedTaskToTop: becameVisibleTask,
});
return;
}
if (becameVisibleTask) {
// v4 预热 session 从 draft 提升,或 online delta 首次出现新 session 时,
// 不经过 zcodeSessionService.createSession。此处是最早且不依赖标题时序的新任务边界;
// 立即回源写入 task 行与 grouped root 最小 sort_order,避免缺序节点落到末尾。
void resyncTaskIndexRowFromAgent(target, "session.became-visible", {
moveGroupedTaskToTop: true,
});
return;
}- 首帧快照只做“缺了才插”,每批 64 行,不广播、不回放历史终态;注释说纯 v4 界面不走旧的初始化路径,若把首帧当成已有存量的静默基线,远端新库会永远是 0 行(
zcodeTaskIndexSyncer.ts:701、zcodeTaskIndexSyncer.ts:56)。 - 同步器是被动观察者,不能为了订阅而拉起 CLI:没有运行时的工作区只留一个休眠占位,等进程管理器报告“可用”才订阅,“不可用”时只清本地订阅关系(
zcodeTaskIndexSyncer.ts:1655、zcodeTaskIndexSyncer.ts:1548、zcodeTaskIndexSyncer.ts:1569)。 - 写路径另有补充:
zcodeSessionService在创建、恢复会话与切换模型之后主动同步一次快照,让侧栏立刻拿到标题和更新时间(zcodeTaskIndexSyncer.ts:113)。 - 同步器还对外发一个“会话就绪”事件,手机远控的命令队列以它作为发送下一条的边界(
zcodeTaskIndexSyncer.ts:167),见远程工作区与手机远控。
Agent 的 V8 字节码
仓库里有一条把 Agent 编译成 V8 字节码运行的路径,但目前只是开发态试验。入口是 pnpm dev:desktop:bytecode(package.json:12),它设 ZCODE_DESKTOP_AGENT_BYTECODE=1,并在构建 Agent 之后多跑一步 build-desktop-agent-bytecode.mjs(scripts/dev-desktop-env.mjs:29、dev-desktop-env.mjs:65);开启 E2E 覆盖率时拒绝执行,脚本自称“字节码试验”(scripts/build-desktop-agent-bytecode.mjs:31)。
怎么编。必须用当前 Electron 以 Node 模式编译(build-desktop-agent-bytecode.mjs:36):把 zcode.cjs 用 Module.wrap 包成 CommonJS 函数,交给 vm.Script,只调用 createCachedData(),不执行模块,避免触发 CLI 的存储、网络和进程副作用(scripts/compile-desktop-agent-bytecode.cjs:15)。编译器和加载器共用一处配置 --no-lazy --no-flush-bytecode:字节码不带可重新编译的源码,函数必须一次编完,也不能被 V8 回收后再从源码重编(scripts/desktop-agent-bytecode-runtime.cjs:10)。产物是三个文件:以摘要命名的 zcode.bytecode-<sha256>.jsc、以内容摘要命名的运行时脚本,以及内嵌元数据的加载器 zcode.bytecode.cjs;前两个按不可变文件写入,加载器最后原子替换,失败时上一版加载器仍能找到自己的字节码(build-desktop-agent-bytecode.mjs:41、build-desktop-agent-bytecode.mjs:48)。
怎么跑。加载器的核心在 desktop-agent-bytecode-runtime.cjs:26:
async function loadBytecode(metadata, targetModule, targetRequire) {
const runtime = configureBytecodeRuntime();
if (JSON.stringify(runtime) !== JSON.stringify(metadata.runtime)) {
throw new Error(
"字节码运行时不匹配,请用当前 Electron 重新运行 pnpm build:desktop-agent:bytecode",
);
}
const directory = dirname(targetModule.filename);
if (basename(metadata.bytecodeFile) !== metadata.bytecodeFile) {
throw new Error("无效的字节码文件名");
}
const cachedData = await readFile(join(directory, metadata.bytecodeFile));
if (bytecodeDigest(cachedData) !== metadata.bytecodeSha256) {
throw new Error("字节码摘要不匹配,请重新构建桌面 Agent");
}
// 使用 ASCII 空格而非双字节零宽字符。这里只消除明文源码,仍保留等长占位内存。
const source = " ".repeat(metadata.sourceLength);
const filename = join(directory, metadata.sourceFile);
const script = new vm.Script(source, {
cachedData,
filename,
// 动态 import 必须交回 Node,继续按原 bundle 的 URL 解析外置 ESM 与原生依赖。
importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
});
if (script.cachedDataRejected) {
throw new Error("V8 拒绝字节码缓存,请重新构建桌面 Agent");
}运行时指纹包括 Electron、Node、V8 版本、平台、架构和 cachedDataVersionTag,任何一项不同都拒绝加载(desktop-agent-bytecode-runtime.cjs:12)。从加载器的做法看,V8 只要求占位源码与原文等长,于是内存里是一串空格而不是明文代码。
为什么。代码没有写成一句话的动机,但痕迹一致:加载器注释说“只消除明文源码”;桌面端的 tsup 配置在生产构建里压缩 main、host、preload,理由是“增加逆向和内部实现暴露风险”(packages/desktop/tsup.config.ts:73);桌面 Agent 的构建同样压缩、不带 sourcemap(apps/zcode-cli/packages/cli/scripts/build.mjs:128)。字节码是在压缩之外再去掉明文。需要注意,它目前只接在开发态的仓库路径上(zcodeAgentProcessManager.ts:358):打包暂存只复制 zcode.cjs(packages/desktop/scripts/stage-agent-bundle.mjs:41),安装包里跑的仍是压缩后的 JS。存储准备用的 Worker 也固定走 JS 入口,因为 Worker 与 Electron Node 子进程的 V8 快照可能不同(zcodeAgentProcessManager.ts:370)。
内嵌浏览器
内置浏览器是 Renderer 里的 <webview> 标签,分区是 persist:zcode-embedded-browser(packages/desktop/src/main/browserDataManager.ts:30),所以主窗口开了 webviewTag(packages/desktop/src/main/desktopWindowChrome.ts:586)。每个 webview 挂载前,Main 在 will-attach-webview 里强制指定 preload,打开 contextIsolation 与 sandbox,删掉页面自带的 preload 和 disablewebsecurity(desktopWindowChrome.ts:640)。
Agent 的 Browser Use 最终也落到这些 webview 上:Agent 发 interaction/browserExecute 给 Host,Host 经 parentPort 转给 Main,单条命令预算 30 秒,比外层 node_repl 工具的 60 秒短,给收尾留余量(packages/desktop/src/host/browserControlMainBridge.ts:50);Main 的 runBrowserCommandOnView 交给 browserGuestManager.execute(packages/desktop/src/main/index.ts:453、main/index.ts:497)。管理器只接受真正的 <webview> guest,拒绝其他类型,以免 CDP 输入打到 ZCode 自己的输入框上(packages/desktop/src/main/browserView/browserGuestManager.ts:550),然后用 webContents.debugger.attach("1.3") 走 CDP(browserGuestManager.ts:696)。DOM 快照借用 Playwright 1.59 生成的注入脚本:从 playwright-core 里只取出那段字符串字面量并做完整性检查,不运行 Playwright 本身(packages/desktop/src/main/browserView/playwrightInjectedScriptSource.ts:32)。Agent 这一侧怎样用 JS REPL 驱动浏览器,见node_repl、Browser Use 与 Computer Use。
更新、终端与历史导入
自动更新用 electron-updater,但 feed 换成了自定义的 ManifestUpdateProvider:向服务端 /api/v1/releases/electron/manifest 取清单,平台写成 darwin-aarch64 这样的形式,渠道 stable 与 preview 分别映射为 1 与 3(packages/desktop/src/main/manifestUpdateProvider.ts:22、manifestUpdateProvider.ts:36、manifestUpdateProvider.ts:70)。每小时检查一次(packages/desktop/src/main/autoUpdater.ts:26);自动下载关闭,先比较远端版本与已下载版本再决定;Windows 上不在退出时自动安装,避免用户紧接着关机把安装器打断(autoUpdater.ts:1501)。打包版忽略 ZCODE_UPDATE_FEED_URL 与 --zcode-update-feed-url 这两个开发用覆盖(autoUpdater.ts:707)。electron-builder 里的 generic 发布地址只是占位,并关掉多 Range 请求,好让 Windows 差分更新不退化成整包下载(packages/desktop/electron-builder.config.js:756)。启动时另有强制升级检查,读的是 /api/v1/client/configs(packages/desktop/src/main/forceUpdateGuard.ts:13)。
终端服务在 packages/services,跑在 Host 里(远端 server 里也有一份)。node-pty 延迟加载,注释说此前顶层导入会让缺少 pty.node 的远端在注册服务阶段就崩掉(packages/services/src/terminal/terminalService.ts:29);Windows 走 ConPTY(terminalService.ts:55)。打包时只解开目标平台的 node-pty 预编译目录(electron-builder.config.js:497)。
导入 Claude Code 会话。扫描的是真实用户 HOME 下的 ~/.claude/projects,与 ZCode 数据目录无关(packages/services/src/session/claude-native/claudeNativeSessionImportRepo.ts:63);原始 jsonl 复制到 ~/.zcode/v2/agent-config/claude/<工作区哈希>/projects(claudeNativeSessionImportRepo.ts:270),工作区目录不存在或与当前工作区不符则跳过(packages/services/src/session/claude-native/claudeNativeSessionImportService.ts:73)。解析出的历史以 importedHistory 交给 Agent 创建一个真正的 ZCode 会话,这样切模型、续聊都能命中运行时,任务行另记 migrationSource: "claudeCode"(packages/services/src/zcode-agent/zcodeTaskServiceAdapter.ts:2626)。
Main 的 mcpUserDirectory 负责读写两处用户级 MCP 配置:~/.zcode/cli/config.json 的 mcp.servers 与 ~/.agents/mcp.json 的 mcpServers(packages/desktop/src/main/mcpUserDirectory/index.ts:37),配置语义见 MCP。
打包与签名
README 的打包命令(README.md:144):
pnpm bundle:desktop
# 指定目标平台与 CPU 架构
pnpm bundle:desktop -- --os win --arch x64
pnpm bundle:desktop -- --help默认目标是 macOS arm64(packages/desktop/scripts/bundle.mjs:42)。脚本依次执行运行资源准备、构建、electron-builder、产物运行时依赖校验与体积审计(bundle.mjs:727)。资源准备包括远程资源(设 ZCODE_SKIP_REMOTE_ASSETS=1 可跳过)、Agent JS bundle、原生搜索工具,macOS 另编一个窗口位置辅助程序(packages/desktop/scripts/prepare-runtime-assets.mjs:29、prepare-runtime-assets.mjs:51)。构建由 tsup 出 main、preload、host、scheduler 四份 bundle,Vite 出 Renderer;生产构建压缩、保留函数名、不带 sourcemap(packages/desktop/tsup.config.ts:67、tsup.config.ts:136)。
| electron-builder 配置 | 取值 |
|---|---|
| Electron 版本 | 写死 41.0.3,语言包只留 en-US 与 zh-CN(electron-builder.config.js:468、electron-builder.config.js:472) |
| 额外资源 | resources/glm 下的 Agent bundle、tools/ripgrep 与原生搜索工具、内置 Provider 配置(electron-builder.config.js:568) |
| 目标 | macOS 为 dmg 与 zip,Windows 为 NSIS,Linux 为 AppImage、deb、rpm、pacman;DMG 容量放大到 3200m(electron-builder.config.js:657、electron-builder.config.js:734) |
| 协议 | 注册 zcode:// 深链(electron-builder.config.js:649) |
| afterPack | 把 pnpm 布局下容易漏拷的运行时依赖补进 app.asar,清掉 sourcemap 引用,校验原生包与 node-pty 预编译(electron-builder.config.js:541) |
签名默认关闭:只有 ZCODE_ENABLE_MAC_SIGN=1 且提供 APPLE_SIGNING_IDENTITY 或 CSC_NAME 时才签名并启用 hardened runtime(electron-builder.config.js:82、electron-builder.config.js:670)。公证不在 electron-builder 里做:macOS 产物先在 build 阶段签名,再由独立阶段公证(electron-builder.config.js:671);已预签名的 glm 与 tools 目录列入 signIgnore,免得重复签名拖长时间(electron-builder.config.js:685)。本地构建因此是未签名的,README 给出的办法是装好后执行 sudo xattr -rd com.apple.quarantine /Applications/ZCode.app(README.md:154)。
下一篇:Web 与服务端:同一套 UI 的另一种宿主——不开 Electron,同一套 @zcode/ui 怎样跑在浏览器里,服务又怎样经 HTTP 与 WebSocket 暴露。