远程工作区与手机远控
ZCode 怎样把 SSH 主机、WSL 发行版与 Docker 容器变成远程工作区:远端部署哪些资源包、两种安装方式与 SHA-256 校验,经 SSH exec 通道的 stdio 跑起远端服务与 Agent;workspaceIdentity、workspacePath 与 remoteSessionId 各管什么,缺席的主机密钥校验;手机远控怎样挂到桌面已有的 Host,以及独立远端服务 zcode-server-cli 的 Supervisor 与控制 IPC。
远程工作区的意思是:项目文件在另一台机器上——一台 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 的分工见上一篇桌面应用。
三种远程目标
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)。
一次连接的全过程
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):
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 生成:
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),缓存在 ElectronuserData下的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):
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)。
SSH 的连接配置由 buildSSHConnectConfig 生成,里面只有认证、readyTimeout 与 keepalive 等参数,没有设置 ssh2 的 hostVerifier(packages/server/src/remote/sshAuth.ts:25),全仓也找不到读取 known_hosts 的代码。从代码看,连接远端时不校验主机密钥,首次连接与密钥变化都不会有提示。在不可信网络上连 SSH 主机时,这一层需要自己另想办法。
认证方面:私钥按路径读入,~ 展开为本机 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):
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 };开源仓库里没有手机端与 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 一侧为手机准备的语义。
两种链路差在哪。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。
手机的输入队列在 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),见输入受理、命令队列与引导。
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):
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 与服务端。
仓库还带了一个 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)。
下一篇:回顾——把全书的要点串起来,与站内其他终端 Agent 对照,并清点文档与代码的出入和短板。