# 远程工作区与手机远控

> ZCode 怎样把 SSH 主机、WSL 发行版与 Docker 容器变成远程工作区：远端部署哪些资源包、两种安装方式与 SHA-256 校验，经 SSH exec 通道的 stdio 跑起远端服务与 Agent；workspaceIdentity、workspacePath 与 remoteSessionId 各管什么，缺席的主机密钥校验；手机远控怎样挂到桌面已有的 Host，以及独立远端服务 zcode-server-cli 的 Supervisor 与控制 IPC。

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

远程工作区的意思是：项目文件在另一台机器上——一台 SSH 主机、一个 WSL 发行版或一个 Docker 容器——界面仍是本机的桌面窗口。桌面端的做法是往远端的 `~/.zcode/server` 部署一套独立的 Node、`zcode-server.cjs` 与 Agent，经 SSH 的 exec 通道（或 `wsl.exe`、`docker exec`）起远端服务，直接在这条通道的 stdio 上跑 RPC；本机的窗口 Host 再把远端服务与本机的账号、凭据拼在一起交给 Renderer。手机远控的方向正好相反：手机不连远端，也不另起 Agent，而是挂到桌面窗口已有的 Host 上。

代码分布：`packages/server/src/remote` 约 1 万行，负责连接、部署、资源缓存与三种后端；窗口 Host 里的 `windowRemoteConnectionRegistry.ts` 管连接的共享与寿命；Main 的 `desktopRemoteSessions.ts` 只做端口转发；目标、身份与资源包的定义在 `packages/shared/src/remote*.ts`；独立远端服务是 `packages/zcode-server-cli`。Main、Host、Renderer 的分工见上一篇[桌面应用](https://daiw.org/manual/zcode/desktop)。

## 三种远程目标

README 的开发说明只有一段（`README.md:67`）：

> 先执行 `pnpm bootstrap:with-remote` 准备远程资源（mock-cdn），再 `pnpm dev:desktop`；连接远程项目时资源选择「本地下载后上传」。开发态资源取自本地 `packages/desktop/mock-cdn` 和本地构建产物，经 SFTP 上传到远程，不访问 CDN。

README 的标题写的是 SSH 与 WSL，代码里的目标其实有三种（`packages/shared/src/remoteTarget.ts:28`），连接向导也提供 Docker 容器（`packages/ui/src/lib/remoteConnectionWizard.ts:111`）：

| 目标 | 关键字段 | 命令通道 | 上传方式 | 物理连接复用 |
| --- | --- | --- | --- | --- |
| SSH | `host`、`port`、`username`，密码或私钥与口令，`assetInstallMode` | ssh2 的 exec 通道，统一进 `/bin/sh` | SFTP，失败一次后本连接改走 `cat >` 管道 | 同一窗口内按主机、端口、用户、认证方式、私钥路径共享 |
| WSL | `distro`、`user` | `wsl.exe -d -u -- bash -lc` | 需要进度或可取消时走 `cat >` 管道，否则先复制到 `\\wsl.localhost` 的 UNC 路径，不通再退回管道 | 按发行版与用户共享，空闲 60 秒回收 |
| Docker | `container` | `docker exec -i` | 一律经 exec 的 `cat >` 写入 | 每个会话独占 |

出处依次是 `packages/server/src/remote/ssh-backend.ts:282`、`ssh-backend.ts:264`、`packages/server/src/remote/wsl-backend.ts:69`、`wsl-backend.ts:206`、`packages/server/src/remote/docker-backend.ts:40`、`docker-backend.ts:91`，连接复用规则见 `packages/desktop/src/host/windowRemoteConnectionRegistry.ts:116` 与 `windowRemoteConnectionRegistry.ts:158`。Docker 不用 `docker cp`，是因为复制进去的文件可能保留宿主的 UID/GID，非 root 用户随后无法 `chmod`（`docker-backend.ts:91`）。远端必须是 POSIX shell 环境，Windows 原生主机直接拒绝（`packages/server/src/remote/remotePlatformSupport.ts:3`）；资源包按 `linux-x64`、`linux-arm64`、`darwin-x64`、`darwin-arm64` 四种平台准备（`scripts/prepare-prebuilds.mjs:52`）。

## 一次连接的全过程

```mermaid
flowchart LR
  subgraph Local["本机"]
    R["Renderer"]
    M["Main"]
    H["窗口 Host 连接注册表"]
    C["本机资源缓存"]
  end
  subgraph Remote["远端 ~/.zcode/server"]
    SV["node zcode-server.cjs"]
    AG["agents/glm/zcode-agent"]
  end
  CDN["ZCode CDN"]
  R -->|"ConnectRemote IPC"| M
  M -->|"ConnectRemoteWorkspace"| H
  R <-->|"ScopedServicePort"| H
  CDN -->|"本地下载后上传"| C
  C -->|"SFTP 或 exec 管道"| Remote
  CDN -.->|"远端服务器下载"| Remote
  H <-->|"SSH exec 通道上的 stdio RPC"| SV
  SV -->|"spawn app-server --stdio"| AG
```

Renderer 经 IPC 发起连接，Main 用 zod 校验目标后，把 `ConnectRemoteWorkspace` 连同本机资源目录发给窗口 Host（`packages/desktop/src/main/desktopMainIpcRemote.ts:411`、`packages/desktop/src/main/desktopRemoteSessions.ts:682`）。Host 的连接注册表先为这次连接分配一个 `remoteSessionId`，再按目标找到或新建一条物理连接（`windowRemoteConnectionRegistry.ts:488`），然后走 `connectRemote` 的五步：探测平台、按需部署、启动远端服务、握手、把 stdio 包成 RPC 通道（`packages/server/src/remote/connect.ts:99`）。远端服务的启动命令只是一行 shell（`connect.ts:363`）：

```ts
  const envParts = [
    `${SERVICE_AUTHORITY_MODE_ENV}="desktop-attached-remote"`,
    'ZCODE_SERVER_RUNTIME_ROOT="$HOME/.zcode/server"',
  ];
  for (const [key, value] of Object.entries(
    pickRemoteRuntimeEnv(options?.remoteRuntimeEnv ?? {}),
  )) {
    envParts.push(`${key}=${quotePosixShellArg(value)}`);
  }
  // ...
  return `${envParts.join(" ")} ~/.zcode/server/node ~/.zcode/server/zcode-server.cjs`;
```

远端 shell 不继承本机 Host 的环境，所以能透传的只有白名单里的几个公开变量（`ZCODE_ENV`、`ZCODE_BASE_URL`、OAuth 来源等），外加应用版本和 WSL 的代理设置，凭据不走这里（`connect.ts:56`、`connect.ts:372`）。

- **握手**。远端服务先往 stdout 写一行 `zcode-hello`，本机逐行读，跳过 SSH 横幅与 motd 这类非 JSON 行，认出合法的 hello 后回一行 `zcode-hello-ack`；两边各等 10 秒（`packages/server/src/remote/handshake.ts:13`、`packages/server/src/entry-stdio.ts:40`、`entry-stdio.ts:90`）。握手后剩下的字节塞回流里，交给 `SocketProtocol`、`ChannelClient` 与 `RemoteServiceAccess`（`connect.ts:213`）。
- **没有端口转发**。RPC 就跑在这条 exec 通道的 stdin 与 stdout 上，所以远端服务把普通 `console` 输出全部改道到 stderr，免得文本混进协议流（`entry-stdio.ts:24`）。stderr 转进本机 Host 的连接日志（`connect.ts:198`）。
- **保活**。SSH 握手超时放宽到 60 秒，每 15 秒一次 keepalive，连续 3 次无响应即断开（`packages/server/src/remote/sshAuth.ts:3`）；半开连接由 backend 的断连事件并入同一条关闭链路（`connect.ts:235`）。

远端这一头是一个以 `desktop-attached-remote` 权威模式创建的完整 `ServiceCollection`（`packages/server/src/stdioServices.ts:32`）。stdio 服务端给 Agent 服务套的连接作用域角色是 `trusted-host-relay`（`packages/server/src/stdio.ts:62`）：本机 Host 已经为每条 attachment 确定了可信的 `clientMode`，远端照单转发，只把 `connectionId` 加上命名空间（`packages/services/src/zcode-agent/zcodeAgentConnectionScope.ts:248`）。远端 Agent 由同一个 `ZCodeAgentProcessManager` 拉起，远端没有 Electron，命令解析一路落到“已部署的二进制”兜底（`packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:391`）。

## 远端要部署什么

资源包一共七个，其中 `server-bundle` 与 `node-runtime` 必需（`packages/shared/src/remoteResourcePackages.ts:1`、`remoteResourcePackages.ts:23`）。旧配置里保存的资源包选择已被忽略，每次都按完整集合检查（`remoteResourcePackages.ts:55`）。

| 资源包 | 远端位置 | 内容 |
| --- | --- | --- |
| `server-bundle` | `~/.zcode/server/zcode-server.cjs` | 远端服务本体（`packages/server/src/remote/deploy.ts:339`） |
| `node-runtime` | `~/.zcode/server/node` | 独立 Node，版本 v22.16.0（`deploy.ts:490`、`prepare-prebuilds.mjs:50`） |
| `node-pty` | `~/.zcode/server/build/Release/pty.node` | 终端（`packages/server/src/remote/remoteAssetDeployDecision.ts:24`） |
| `glm` | `~/.zcode/server/agents/glm/` | Agent 的 `zcode.cjs` 与官方插件（`packages/server/src/remote/zcodeAgentDeploy.ts:37`） |
| `bfs`、`ripgrep`、`ugrep` | `~/.zcode/server/tools/` | 搜索工具：Linux 三件都部署，macOS 只部署 ripgrep 13（`packages/shared/src/runtime-tool-runtime.ts:41`） |

远端的 Agent 不是原生二进制，而是“远端 Node 加 `zcode.cjs`”：部署时在 `agents/glm/` 下放一个名叫 `zcode-agent` 的 shell 包装脚本，进程管理器照旧去找这个可执行文件，不必区分原生还是 JS（`packages/server/src/remote/zcodeAgentBundleWrapper.ts:1`）。脚本内容由 `zcodeAgentBundleWrapper.ts:11` 生成：

```ts
export function buildRemoteAgentBundleWrapper(runtimeResourceDir: string): string {
  return [
    "#!/bin/sh",
    "set -eu",
    'runtime_root="${ZCODE_SERVER_RUNTIME_ROOT:-$HOME/.zcode/server}"',
    `exec "$runtime_root/node" "$HOME/.zcode/server/agents/${runtimeResourceDir}/${REMOTE_AGENT_BUNDLE_NAME}" "$@"`,
    "",
  ].join("\n");
}
```

有几处注释还停留在旧说法：electron-builder 配置说“远端 SSH/WSL 仍走原生二进制”（`packages/desktop/electron-builder.config.js:626`），资源准备脚本说“远端跨平台原生二进制仍由 prepare:remote-assets 提供”（`packages/desktop/scripts/prepare-runtime-assets.mjs:27`），运行时描述符的 `binaryKind` 也还写着 `native-binary`（`packages/shared/src/zcode-agent-runtime.ts:27`）；实际的准备脚本给每个平台放的都是同一份 `zcode.cjs`（`prepare-prebuilds.mjs:507`）。

**要不要重新部署**，按下面的顺序判断（`deploy.ts:481`）：远端 `node` 与 `zcode-server.cjs` 是否都在；`zcode-server.cjs --version` 是否等于本机版本；已部署的 bundle 里是否含有 `skill-sync`、`mcp-sync`、`plugin-sync` 等必需标记（`packages/server/src/remote/serverBundleDeployCheck.ts:4`）；远端记下的组件 SHA-256 是否与清单一致（`deploy.ts:532`）。应用版本一变，内容寻址的资源一律强制刷新，并在远端留一个“待刷新”标记，前面步骤失败的重试也会继续绕过缓存（`deploy.ts:259`）。多个客户端同时部署时，靠远端 `~/.zcode/server/.deploy.lock` 目录锁串行：每 30 秒心跳，600 秒无心跳算陈旧，获取最多等 120 秒（`packages/server/src/remote/remoteDeployLock.ts:7`、`remoteDeployLock.ts:118`）；SSH 已由窗口级注册表保证同一目标只有一个部署事务，于是改用 `caller-serialized`，不再占一条 SSH 通道持锁（`deploy.ts:418`、`packages/desktop/src/host/index.ts:1636`）。

### 两种安装方式与校验

SSH 目标可以选资源怎么到达远端，界面上叫“本地下载后上传”与“远端服务器下载”，默认前者（`packages/shared/src/remoteAssetInstallMode.ts:1`、`packages/ui/src/i18n/locales/zh-CN.ts:1294`）；WSL 与 Docker 不看这个选项（`host/index.ts:2929`）。

- **本地下载后上传**。本机从 CDN 取组件包，默认地址是 `https://cdn-zcode.z.ai` 下的 `/zcode/electron/releases/` 加版本号（`packages/desktop/src/main/remoteCdn.ts:4`、`remoteCdn.ts:30`），缓存在 Electron `userData` 下的 `remote-assets-cache`，可用 `ZCODE_REMOTE_ASSET_CACHE_DIR` 改位置（`packages/desktop/src/main/desktopRuntimeEnv.ts:292`）；校验、解压后经 SFTP 上传。
- **远端服务器下载**。先在远端探测工具：要有 `curl` 或 `wget`，要有 `tar`，还要有 `sha256sum`、`shasum`、`openssl` 之一，缺一个就提示切回本地上传（`packages/server/src/remote/remoteAssetPreflight.ts:33`）。下载命令是 `curl -fL --retry 2 --connect-timeout 20` 或 `wget --tries=3 --timeout=20`，下完在远端算摘要，不符即失败退出（`packages/server/src/remote/remoteAssetInstaller.ts:111`、`remoteAssetInstaller.ts:880`）。

两条路径共用同一份清单：CDN 上每个平台一个 `manifest-<platformArch>.json`，每个组件写明 `id`、`version`、`sha256`、`artifactPath`、`mount`（`packages/server/src/remote/remoteAssetCache.ts:40`、`remoteAssetCache.ts:63`）。解析时摘要必须是 64 位十六进制，`mount` 必须等于该组件固定的挂载规则，未知组件直接跳过（`remoteAssetCache.ts:1300`、`remoteAssetCache.ts:1310`、`remoteAssetCache.ts:1323`）；本机缓存在解压前核对摘要（`remoteAssetCache.ts:950`）。清单本身没有签名，完整性靠 HTTPS 与清单里的摘要。

开发态默认不访问 CDN：`pnpm bootstrap:with-remote` 经桌面包的 `prepare:remote-assets` 调 `prepare-prebuilds.mjs`（`packages/desktop/package.json:16`），在 `packages/desktop/mock-cdn/releases/<版本>/` 下生成四个平台的清单与组件（`prepare-prebuilds.mjs:42`）；只有这个版本目录存在时才启用 mock-cdn，否则回落到 CDN 与缓存（`desktopRuntimeEnv.ts:207`）；设 `ZCODE_DEV_REMOTE_ASSET_USE_CDN` 可以在开发态直接验证真实 CDN（`desktopRuntimeEnv.ts:224`）。

## 本机 Host 这一侧

AGENTS.md 的约定是远程工作区不另起进程（`AGENTS.md:61`）：

> 每个窗口使用一个 window-scoped Local Host；本地 workspace 共享该 Host。远程 workspace 由窗口内的连接注册表管理，不另建 Desktop Remote Host。

连接成功后，Host 用远端的文件、Agent、任务等服务，加上本机的设置、凭据与账号服务，拼成这个远程会话的 `ServiceCollection`（`packages/desktop/src/host/remoteWorkspaceServiceCollection.ts:84`）；注释写明，挂在桌面上的远端只复用本机已解析的 Key，账号刷新仍由本机负责（`remoteWorkspaceServiceCollection.ts:131`）。个人 Provider 配置与凭据在环境上线和变化时由 Host 现读现推到远端，Main 只协调“环境”这一层的身份与代次（`packages/desktop/src/main/providerProvisioningEnvironmentCoordinator.ts:19`）；NOTICE 专门提醒这类同步没有逐项确认（`NOTICE.md:43`）。技能、MCP、插件也可以在设置页里同步到远端（`packages/services/src/skill-sync/skillSync.ts:11`）。

每个远程会话在 Renderer 里对应一条单独的 `ScopedServicePort`：Main 建一对端口，一端经 `AttachServicePort` 交给 Host，另一端投给 Renderer，15 秒内收不到 Renderer 的就绪回执就撤销（`desktopRemoteSessions.ts:254`、`desktopRemoteSessions.ts:266`）。Renderer 刷新时，Main 按同样的方式为仍可接入的远程会话补挂端口（`desktopRemoteSessions.ts:735`）。

## 身份：workspaceIdentity、workspacePath 与 remoteSessionId

AGENTS.md 把规则写得很硬（`AGENTS.md:70`）：

> - `workspaceIdentity` 用于身份隔离，`workspacePath` 用于文件操作、命令 cwd、Git 和路径展示。
> - 身份 key 统一为 `workspaceIdentity?.trim() || workspacePath`，适用于去重、绑定、缓存、队列、持久化和请求关联。

远程身份只能由统一工具构造（`packages/shared/src/remote-workspace-identity.ts:46`）：

```ts
export function buildRemoteWorkspaceIdentity(workspacePath: string, target: RemoteTarget): string {
  const normalizedPath = normalizeWorkspacePathForIdentity(workspacePath);
  switch (target.kind) {
    case "ssh":
      return `remote:ssh:${target.host.trim().toLowerCase()}:${target.port ?? 22}:${target.username.trim()}:${normalizedPath}`;
    case "wsl": {
      const distro = target.distro?.trim() || "default";
      const user = target.user?.trim();
      return user
        ? `remote:wsl:${distro}:${user}:${normalizedPath}`
        : `remote:wsl:${distro}:${normalizedPath}`;
    }
    case "docker":
      return `remote:docker:${target.container}:${normalizedPath}`;
  }
}
```

路径段只在这里归一（反斜杠改斜杠、合并连续斜杠、去掉首尾斜杠后补一个前导斜杠），实际 IO 仍用调用方的原路径（`remote-workspace-identity.ts:36`、`remote-workspace-identity.ts:42`）。不过 UI 里还有一个同名的构造函数，这个文件开头的注释称它为“构造侧（对偶）”（`packages/ui/src/lib/remoteWorkspaceHistory.ts:137`、`remote-workspace-identity.ts:3`）；两份的归一化并不相同，UI 版只换分隔符、去掉尾部斜杠，不合并连续斜杠也不补前导斜杠，WSL 发行版名不去空白（`remoteWorkspaceHistory.ts:110`、`remoteWorkspaceHistory.ts:129`）。常规的绝对路径算出的结果一致，边角输入可能不同；连接选定目录后由 Renderer 把 UI 算出的身份绑回 Main 与 Host（见下文），两边靠这一步对齐。几种身份各有用途：

| 名字 | 形态 | 用途 |
| --- | --- | --- |
| `workspacePath` | 远端真实路径 | 文件操作、命令 cwd、Git、展示 |
| `workspaceIdentity` | `remote:ssh:主机:端口:用户:路径` 等 | 隔离；随 `ZCODE_WORKSPACE_IDENTITY` 传给 Agent（`packages/services/src/runtime-tools/agentProxyEnv.ts:116`） |
| 工作区 key | `workspaceIdentity` 去空白，缺省用路径 | Agent 进程池、订阅、队列、任务索引的键（`packages/shared/src/task-realtime-core.ts:78`） |
| `remoteSessionId` | 每次连接一个 UUID | 一个逻辑远程会话，attachment 的 scope 与 Main 的端口路由都靠它（`host/index.ts:1743`） |
| 连接 key | SSH 为 `buildSshRemoteHostKey` 的结果 | 窗口内复用物理连接（`windowRemoteConnectionRegistry.ts:116`） |
| 环境 key | `ssh:`、`wsl:`、`docker:` 开头 | Provider 下发等环境级状态，注释要求不得与工作区或会话身份混用（`packages/shared/src/remoteEnvironmentKey.ts:4`） |

CLI 一侧按同一个解析器把 `remote:` 开头的 workspaceId 还原成真实路径；`remote:` 开头却解析失败的直接报错，不退回当成本地路径，免得把身份字符串写进工作目录（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/workspace.ts:18`）。`remoteSessionId` 与身份的区别体现在重连上：新连接成功后，Host 找出路径与身份都相同、已经断开的旧会话，把任务列表来源原子替换到新会话，再回收旧的 `remoteSessionId`（`host/index.ts:2555`）。选定远端目录后，Renderer 还要把 canonical 路径与身份绑回 Main 与 Host，代次加一（`desktopRemoteSessions.ts:693`）；UI 里的注释说，这一步曾在重构时被删，导致新建连接后手机端必然因身份不符被拒（`packages/ui/src/root/useRemoteWorkspaceHistory.ts:547`）。

## 主机密钥：代码里没有校验

`remoteSshHostKey.ts` 这个文件名容易让人以为与 SSH 主机密钥有关，其实它只生成“窗口内 SSH Remote Host 的共享身份”，也就是上表的连接 key：主机名小写、端口、用户名、认证方式与规范化后的私钥路径，密码与口令不进键（`packages/shared/src/remoteSshHostKey.ts:121`）。

<Callout type="warn">
  SSH 的连接配置由 `buildSSHConnectConfig` 生成，里面只有认证、`readyTimeout` 与 keepalive 等参数，没有设置 ssh2 的 `hostVerifier`（`packages/server/src/remote/sshAuth.ts:25`），全仓也找不到读取 `known_hosts` 的代码。从代码看，连接远端时不校验主机密钥，首次连接与密钥变化都不会有提示。在不可信网络上连 SSH 主机时，这一层需要自己另想办法。
</Callout>

认证方面：私钥按路径读入，`~` 展开为本机 HOME（`packages/server/src/remote/create-backend.ts:10`）；用密码登录时不再隐式带上 `SSH_AUTH_SOCK`，因为有的主机 `MaxAuthTries` 很小，代理里的公钥会先把次数耗光（`sshAuth.ts:28`）；密码还会用来应答 keyboard-interactive（`sshAuth.ts:48`）。密码与口令只存在于建连流程，进入长期状态和跨进程回包前会被剥掉（`remoteTarget.ts:30`）。

## 手机远控

AGENTS.md 对手机远控的约束有三条（`AGENTS.md:62`）：

> - 手机远控连接桌面已有 Host attachment，复用会话运行时；不为手机另起 Agent、Local Host 或远程会话。
> - Desktop 的 `desktop-continuous` 实时链路与手机的 `web-remote-replayable` 恢复链路必须明确区分。修改 stream、snapshot、queue 或重连时，同时验证两种语义。
> - 外部 relay 与 Main 只做鉴权、配对、心跳、转发及 attachment 调度，不保存任务队列、快照等业务状态。

**挂到哪里**。Host 的 `AttachServicePort` 消息只允许 Main 声明两种来源：桌面刷新或远程补挂必须是 `desktop-continuous`，手机必须是 `web-remote-replayable`（`packages/shared/src/validation.ts:263`）。每条 attachment 在 Host 里各有一个 `ChannelServer` 与连接作用域，服务集合是共享的（`host/index.ts:1964`）；作用域会先清掉入参里所有可伪造的连接字段，再写入 Host 认定的值，所以 Renderer 与手机都冒充不了对方的模式（`zcodeAgentConnectionScope.ts:60`、`zcodeAgentConnectionScope.ts:89`）。Main 里为远程工作区准备的手机桥接入口是 `attachRemoteWorkspaceSessionHost`，它先核对会话存在、在线、属于当前窗口，再要求路径、身份、工作区 key 三者全等，才向 Host 要一条 replayable 端口（`desktopRemoteSessions.ts:864`）：

```ts
    const descriptor = route.descriptor;
    if (
      descriptor.workspacePath !== params.workspacePath ||
      descriptor.workspaceIdentity !== params.workspaceIdentity ||
      params.workspaceKey !== params.workspaceIdentity
    ) {
      throw Object.assign(new Error("远程 workspaceKey 与 logical session 不匹配。"), {
        code: "REMOTE_WORKSPACE_IDENTITY_MISMATCH" as const,
      });
    }
    const process = getWindowHost(win);
    const { port1, port2 } = createMessageChannel();
    process.postMessage(
      {
        type: HostMessageTypes.AttachServicePort,
        requestId: randomUUID(),
        attachmentId: randomUUID(),
        clientMode: params.clientMode,
        scope: {
          kind: "remote",
          remoteSessionId: params.remoteSessionId,
          workspacePath: params.workspacePath,
          workspaceIdentity: params.workspaceIdentity,
        },
      },
      [port2],
    );
    return { process, port: port1, remoteKind: descriptor.target.kind };
```

<Callout type="info">
  开源仓库里没有手机端与 relay 本身。`attachRemoteWorkspaceSessionHost` 在仓库里没有任何调用方；任务实时总线预留了 `relay_bridge` 投递类型，但没有哪个 Host 以它注册（`packages/shared/src/task-realtime.ts:78`）；Web 包里手机远控的路由与 relay 地址只剩两行环境变量声明（`packages/web/src/env.d.ts:14`、`env.d.ts:17`）；也找不到配对、扫码相关的代码。能读到的是 Host 与 Agent 一侧为手机准备的语义。
</Callout>

**两种链路差在哪**。v4 握手要求连接的投递档位与 `clientMode` 一致（`packages/shared/src/zcode-protocol-v4/transport.ts:49`），CLI 只认 Host 注入的可信 `clientMode` 来选档位（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/v4-gateway.ts:1395`）。两个档位定义在 `packages/shared/src/zcode-protocol-v4/core.ts:34`，字段不少，但目前真正起作用的只有两项：

| 参数 | `continuous`（桌面） | `replayable`（手机） |
| --- | --- | --- |
| 合批窗口 | 30 毫秒 | 150 毫秒 |
| 按增量推送的字段 | 正文、工具输入、工具输出、摘要 | 只有正文，其余等整行定稿时一次给出 |

合批窗口由 gateway 按订阅调度（`v4-gateway.ts:1451`），增量过滤在 `packages/shared/src/zcode-protocol-v4/profiles.ts:27`。档位里的 `desktopOnlyRows` 对应的行过滤函数眼下原样返回（`profiles.ts:13`），`toolProgress` 与 `streamOutputCapBytes` 在仓库里找不到读取方。

恢复语义是水位续传：客户端重订时带上已确认的 `logEpoch` 与 `seq`，也可以要求强制快照（`transport.ts:438`）。纪元相同、`seq` 仍在保留窗内（每会话保留 2000 条事件，`zcode-protocol-v4/core.ts:74`），就只重放这之后的一段，而且与在线续流走同一条过滤合并管线；否则发一份快照，只带尾部 60 行（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/conversation-topic-publisher.ts:743`、`conversation-topic-publisher.ts:765`、`zcode-protocol-v4/core.ts:75`）。`profiles.ts` 的注释要求被过滤掉的增量必须由一条不可过滤的整行收口，并提到一项“两 profile 终态逐字节一致”的黄金测试（`profiles.ts:5`）；开源仓库里只有 4 个测试文件，找不到这项测试。视频附件预览只有 continuous 链路走 Host 的回环 Range 数据面，手机一律分块内联（`v4-gateway.ts:2142`、`host/index.ts:1993`）。协议层的完整语义见[ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol)。

**手机的输入队列在 Host**。手机提交的消息先进 Host 服务里的运行时命令队列，状态为 `accepted`（`packages/services/src/zcode-agent/zcodeTaskServiceAdapter.ts:1914`）；任务空闲时由 Host 自己开始消费，下一条的发送边界是任务索引同步器发出的“会话就绪”事件，而不是固定延时（`zcodeTaskServiceAdapter.ts:1713`、`packages/services/src/zcode-agent/zcodeTaskIndexSyncer.ts:167`）。删除排队的命令也必须删 Host 队列，只清前端的话，下一次快照会把它恢复回来（`zcodeTaskServiceAdapter.ts:2002`）。桌面的 continuous 链路不走这个队列（`zcodeTaskServiceAdapter.ts:1939`）。已经交给 Agent 的忙时输入，仍由 CLI 的 `CommandInbox` 串行受理（`AGENTS.md:65`），见[输入受理、命令队列与引导](https://daiw.org/manual/zcode/prompt-admission)。

Main 里的任务实时总线也在这条路径上，只管路由。读的一面：远程任务发送前，Host 向总线申请这次运行的租约，拿到才把用户消息和流事件按批次镜像出去，拿不到（已被别的 Host 持有）就照常发送、不做镜像（`host/index.ts:1269`、`host/index.ts:1275`，租约判定见 `packages/desktop/src/main/taskRealtimeBus.ts:317`）；注释写明这份镜像的去向是“taskRealtimePort → 手机 relay → 手机端”，而且仍沿用旧的 `ZCodeStreamEvent` 词表（`host/index.ts:1287`）。总线为后接入的 Host 保留最多 60 批、512 KB 的回放（`taskRealtimeBus.ts:25`）。写的一面：停止生成、审批与问卷应答、工作区钩子审查，以及入队、提升、取消排队命令，都作为 owner 命令转给持有租约的 Host 执行，30 秒没有结果算失败（`task-realtime.ts:98`、`task-realtime.ts:147`、`taskRealtimeBus.ts:843`、`taskRealtimeBus.ts:29`）。

## 独立远端服务：zcode-server-cli

`packages/zcode-server-cli` 是另一种远端形态：在一台机器上常驻一个 ZCode Server，而不是每次由桌面经 SSH 临时拉起。包的可执行名也是 `zcode`（`packages/zcode-server-cli/package.json:4`），自己只处理 `serve`、`status`、`stop`、`restart`、`update`、`uninstall`，其余参数交给同目录的 Agent CLI `zcode.cjs`（`packages/zcode-server-cli/src/cli.ts:89`、`cli.ts:505`）。数据根默认是 `~/.zcode/server`（设了 `ZCODE_DATA_BASE_DIR` 则放在它下面），与桌面经 SSH 部署用的根目录是同一个路径；下面有 `releases/`、`current.json`、`run/status.json`、`run/server.lock`，控制端点在 POSIX 上是 `run/control.sock`，Windows 上是命名管道（`packages/zcode-server-cli/src/runtime/paths.ts:23`、`paths.ts:28`）。

| 层 | 做什么 | 出处 |
| --- | --- | --- |
| `serve --daemon` | 注册为系统服务：launchd、`systemctl --user` 或 Windows 计划任务；设 `ZCODE_SERVER_SKIP_SERVICE_REGISTRATION=1` 则改为 detached 子进程 | `cli.ts:54`、`cli.ts:179`、`packages/zcode-server-cli/src/platform/serviceManager.ts:7` |
| Supervisor | 持有数据根锁与控制 socket，fork Core；崩溃后依次等 1、2、4、8、16 秒再拉起，5 分钟窗口内第 6 次崩溃即停在 `crash-loop-stopped` | `packages/zcode-server-cli/src/supervisor/supervisor.ts:375`、`packages/zcode-server-cli/src/supervisor/crashBudget.ts:18`、`packages/zcode-server-cli/src/contracts.ts:5` |
| Core | 以 `standalone-server` 权威模式创建服务，经 IPC 报 `ready`，每 10 秒发心跳与运行中任务数 | `packages/zcode-server-cli/src/server-core/core.ts:40`、`server-core/core.ts:75` |
| 控制 IPC | 每行一个 JSON，命令有 `ping`、`status`、`stop`、`restart`、`prepare-update`、`apply-update`、`prepare-uninstall`、`confirm-uninstall`；socket 权限 0600 | `contracts.ts:85`、`packages/zcode-server-cli/src/ipc/controlServer.ts:40` |

Supervisor 与 Core 之间走 Node 的 `fork` IPC 通道，Core 的 stdio 全部丢弃（`cli.ts:263`）；启动与更新时等 Core 就绪都以 15 秒为限（`cli.ts:482`、`supervisor.ts:282`）。Core 的 HTTP 服务只允许监听回环地址（`packages/zcode-server-cli/src/server-core/http.ts:124`）：

```ts
  const app = new Hono();
  const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
  const host = options.host ?? "127.0.0.1";
  if (!isLoopbackHost(host)) {
    // 当前只有本机/SSH 隧道入口，Core 尚未接入 token middleware；对外监听必须 fail-closed。
    throw new Error(
      `Non-loopback host ${host} requires authentication before the server can listen`,
    );
  }
```

NOTICE 也据此声明“独立远端服务核心仅接受本机回环监听地址，未提供同等的外部账号鉴权”（`NOTICE.md:30`）。路由有三条：`/api/server-info`；`/ws` 一律按 `web-remote-replayable` 的终端客户端处理；`/ws/host` 要带一次性的 host capability 请求头，才以 `desktop-continuous` 的可信中继身份接入（`server-core/http.ts:148`）。capability 由 `POST /api/rpc-host-capability` 签发，有效期 30 秒，消费一次即作废（`packages/zcode-server-cli/src/server-core/hostCapability.ts:7`、`hostCapability.ts:45`）；签发接口本身不设鉴权，保护边界就是回环监听。

开源版桌面端还连不上这种服务：`RemoteTarget` 只有 ssh、wsl、docker 三种（`remoteTarget.ts:28`），Main 的遥测函数里却还留着一个 `server` 分支，读的是目标上并不存在的 `url` 字段（`desktopRemoteSessions.ts:93`）；根目录的 `pnpm typecheck` 只检查桌面端的 host 子工程（`package.json:29`），这段残留不会在那里报错。Web 模式同样能经 `/api/connect-remote` 复用 `connectRemote` 连远程目标（`packages/server/src/http.ts:346`），见[Web 与服务端](https://daiw.org/manual/zcode/server-web)。

仓库还带了一个 SSH 测试靶机：Ubuntu 22.04 装 openssh-server，root 与 dev 两个账号的密码与用户名相同，允许 root 密码登录（`harness/remote/Dockerfile:2`、`harness/remote/Dockerfile:11`）。它的说明只有两行命令，运行的镜像名是 `ssh-server:latest`，而 `build.sh` 打的标签是 `my-ssh-server`，还传了一个 Dockerfile 并未声明的 `SSH_PUBLIC_KEY` 构建参数（`harness/remote/README.md:1`、`harness/remote/build.sh:3`）。

下一篇：[回顾](https://daiw.org/manual/zcode/recap)——把全书的要点串起来，与站内其他终端 Agent 对照，并清点文档与代码的出入和短板。
