# 桌面应用：Electron 的三层

> ZCode 桌面端怎样把 Electron 拆成 Main、窗口级 Local Host 与 Renderer：Host 用 Electron 自带的 Node 拉起 app-server 模式的 Agent 子进程并管理其生命周期，任务列表怎样与 Agent 会话库同步；另有 V8 字节码试验、内嵌浏览器、自动更新、终端、Claude Code 历史导入与打包签名。

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

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](https://daiw.org/manual/zcode/zcode-protocol)，远程工作区与手机远控留给[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)。

| 位置 | 职责 |
| --- | --- |
| `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 链路上的会话操作 |

## 进程与分工

```mermaid
flowchart LR
  M["Main 主进程"]
  R["Renderer（每窗口一个）"]
  H["Local Host（每窗口一个）"]
  S["Scheduler"]
  W["webview 内嵌浏览器"]
  A1["Agent chat 泳道"]
  A2["Agent plugin / mcp-status 泳道"]
  M -->|"BrowserWindow + preload"| R
  M -->|"utilityProcess.fork"| H
  M -->|"utilityProcess.fork"| S
  R <-->|"MessagePort RPC"| H
  H <-->|parentPort| M
  S <-->|parentPort| M
  H -->|"spawn zcode.cjs app-server --stdio"| A1
  H --> A2
  M -->|CDP| W
```

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-scheduler` fork 出来（`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`）。细节见[定时任务与闲时任务](https://daiw.org/manual/zcode/cron-offpeak)。

## 每个窗口一个 Local Host

Host 入口文件开头的注释把拓扑画得很直白（`packages/desktop/src/host/index.ts:3`）：

```ts
/**
 * 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`）：

```ts
    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`）：

```ts
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 会话库](https://daiw.org/manual/zcode/session-store)）。侧栏的任务列表另有一个库 `~/.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`）。

```mermaid
sequenceDiagram
  participant A as Agent 会话库
  participant S as TaskIndexSyncer
  participant T as tasks-index.sqlite
  participant R as Renderer 侧栏
  S->>A: 订阅 sessions-index 与 workspace-config
  A-->>S: 初始快照
  S->>T: 缺失行补齐，每批 64 行，不广播
  A-->>S: 在线增量 SessionSummary
  S->>T: 终态、标题、新可见会话
  S-->>R: workspace_task_list_changed
```

每条会话摘要的处理规则集中在 `processSummary`（`zcodeTaskIndexSyncer.ts:669`）：

```ts
    // 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`），见[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)。

## 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`：

```js
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](https://daiw.org/manual/zcode/node-repl-browser)。

## 更新、终端与历史导入

**自动更新**用 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](https://daiw.org/manual/zcode/mcp)。

## 打包与签名

README 的打包命令（`README.md:144`）：

```bash
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 的另一种宿主](https://daiw.org/manual/zcode/server-web)——不开 Electron，同一套 `@zcode/ui` 怎样跑在浏览器里，服务又怎样经 HTTP 与 WebSocket 暴露。
