综合 / 开发工具 · 2026-10-17 09:40 · 1 阅读 · 0 赞
基于 Pi 生态构建 AI 编程桌面工作台:Tether 架构解析
Tether 是一个基于 Pi 生态的本地 AI 编程桌面工作台,采用 Electron + React 构建。本文解析其进程隔离、IPC 契约、文件路径校验、JSON-RPC 宿主、多会话路由、Checkpoint 撤销与消息队列等关键设计。
基于 Pi 生态构建 AI 编程桌面工作台:Tether 架构解析
官网:http://tether-code.xyz 源码:https://github.com/tt-11-dd/tether-ai(MIT)
一、概述
Tether 是一个面向真实代码仓库的 AI 编程桌面工作台。模型调用、文件工具、终端命令、权限审批、会话记录与 Diff 审查集中在一个 Electron 应用中;配置、凭据与会话存放在本机 ~/.tether,模型请求直连用户配置的服务商或本地网关,不经过任何中转服务。
项目没有重新实现 Agent 基础设施。Agent 循环、模型协议、工具调用与 RPC 来自开源的 Pi 生态(@earendil-works/pi-*),由 npm 包 tether-agent-core 在其上补充权限模型、沙箱、文件 Checkpoint、MCP 与 Hooks。桌面端仓库只负责三件事:Electron 壳、React 界面,以及把 Agent 子进程桥接到桌面。
| 技术 | 用途 |
|---|---|
| Electron 37 | 桌面壳、主进程、contextBridge 隔离 |
| React 19 | 渲染进程 UI |
| TypeScript 5.9(strict) | 全部源码 |
| Vite 7 / tsup 8 | 渲染进程构建 / 主进程与 preload 构建 |
| Vitest 3 | 单元测试 |
| tether-agent-core 0.1.21 | Agent 运行时(RPC worker、沙箱、会话、Checkpoint) |
本文围绕几个关键设计展开:进程隔离、IPC 契约、文件访问校验、RPC 宿主、多会话路由、撤销机制与消息队列。

二、整体架构
React Renderer(src/renderer)
会话渲染、Diff 面板、设置、项目与会话导航
│ contextBridge(window.harness)/ Electron IPC
▼
Electron Main(src/main)
窗口、工作区文件、凭据、AgentHost 子进程宿主
│ 换行分隔 JSON-RPC over stdio
▼
tether-agent-core(独立 RPC worker 进程)
权限、沙箱、工具、Checkpoint、MCP、会话文件
│
▼
Pi 生态
Agent loop · 模型协议 · coding-agent 扩展 · RPC
分层遵循两条原则:
- 渲染进程零特权。
BrowserWindow开启contextIsolation与sandbox,关闭nodeIntegration,所有桌面能力都必须经过类型化的 IPC 契约。 - Agent 与 UI 生命周期解耦。Agent 运行在独立子进程中,通过
process.execPath配合ELECTRON_RUN_AS_NODE=1启动。Agent 崩溃不会影响窗口;已落盘的会话可继续作为对话恢复,但不会自动重放未完成的命令。

三、IPC 契约:接口先行
渲染进程可调用的全部能力定义在 src/shared/types.ts 的 DesktopApi 接口中,主进程与渲染进程共享这份类型:
// src/shared/types.ts(节选)
export interface DesktopApi {
platform: NodeJS.Platform;
workspace: {
choose(): Promise<string | null>;
read(path: string, cwd?: string): Promise<{ path: string; content: string; binary: boolean; missing?: boolean }>;
list(cwd?: string): Promise<string[]>;
restore(files: Array<{ path: string; content: string | null; mode?: number }>, cwd?: string): Promise<{ restored: string[] }>;
onChanged(listener: (root: string) => void): () => void;
};
// app / window / vision / services / sessions / auth / agent ...
}
preload 是该接口的机械实现:每个方法对应一条 ipcRenderer.invoke,订阅类方法统一返回取消函数,不包含业务逻辑。
// src/preload/index.ts(节选)
function subscribe<T>(channel: string, listener: (payload: T) => void): () => void {
const handler = (_event: Electron.IpcRendererEvent, payload: T) => listener(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
const api: DesktopApi = {
// ...
agent: {
start: (options) => ipcRenderer.invoke("agent:start", options),
stop: (sessionPath) => ipcRenderer.invoke("agent:stop", sessionPath),
command: (type, data, sessionPath) => ipcRenderer.invoke("agent:command", type, data, sessionPath),
onEvent: (listener) => subscribe<AgentEvent>("agent:event", listener),
},
};
contextBridge.exposeInMainWorld("harness", api);
这样做的收益是:契约之外的能力在编译期无法调用,运行期又有 contextIsolation 兜底;新增一项桌面能力必须同时修改接口、preload 与主进程 handler,边界变更在代码评审中清晰可见。
四、主进程的文件访问校验
IPC 契约只约束了“能调用什么”,参数本身仍来自渲染进程,属于不可信输入。所有涉及工作区文件的 handler 都经过 resolveInWorkspace,它做三层校验:
- 工作区白名单:目标根目录必须是当前 Agent 的 cwd,或在最近打开的工作区列表中。
- 词法校验:
path.resolve后必须位于根目录之内,拦截../穿越。 - realpath 复核:词法校验挡不住符号链接(例如工作区内一个指向
~/.ssh的 link),因此对根目录和目标路径各取一次 realpath 再比较。目标文件尚不存在时,realpathExistingOrJoin逐级向上找到第一个存在的父目录求 realpath,再拼回剩余路径。
// src/main/workspace-path.ts
export function isPathInsideRoot(root: string, candidate: string): boolean {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(candidate);
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
return resolved === resolvedRoot || resolved.startsWith(prefix);
}
前缀比较时补上分隔符,是为了避免 /repo 误判 /repo-other 为子路径。
五、AgentHost:stdio 上的 JSON-RPC 客户端
src/main/agent-host.ts 负责拉起 RPC worker、收发消息与回收进程。
5.1 按行切分时保证 UTF-8 完整
stdout 以 Buffer 分块到达,一个中文字符的多个字节可能被拆进两个 chunk。如果每个 chunk 先 toString() 再按 \n 切分,就会出现乱码。drainUtf8Lines 在字节层面按 0x0a 切分,只把完整的行解码为字符串,剩余字节留到下一次拼接:
// src/main/rpc-lines.ts
export function drainUtf8Lines(buffer: Buffer, chunk: Buffer): { rest: Buffer; lines: string[] } {
const combined = Buffer.concat([buffer, chunk]);
const lines: string[] = [];
let start = 0;
let index = combined.indexOf(0x0a, start);
while (index >= 0) {
const line = combined.subarray(start, index).toString("utf8").replace(/\r$/, "");
if (line) lines.push(line);
start = index + 1;
index = combined.indexOf(0x0a, start);
}
return { rest: Buffer.from(combined.subarray(start)) as Buffer, lines };
}
由于 UTF-8 的多字节序列中不会出现 0x0a,按换行字节切分天然不会截断字符。
5.2 请求、响应与分级超时
每个请求分配自增 id,挂入 pending 表,收到同 id 的 response 后结算。超时按请求类型分两档:
const DEFAULT_RPC_TIMEOUT_MS = 45_000;
const LONG_RPC_TIMEOUT_MS = 30 * 60_000;
const LONG_RUNNING_REQUESTS = new Set([
"prompt", "steer", "abort", "get_entries", "get_fork_messages",
"get_messages", "get_session_stats", "fork", "compact",
]);
function timeoutForRequest(type: string): number {
return LONG_RUNNING_REQUESTS.has(type) ? LONG_RPC_TIMEOUT_MS : DEFAULT_RPC_TIMEOUT_MS;
}
get_state 这类查询应当快速失败;而一轮 prompt 或上下文压缩 compact 可能持续数分钟,不能与查询共用同一个超时阈值。超时报错会附带 worker 的 stderr(上限 20 万字符),便于区分是模型侧无响应还是 worker 自身异常。
5.3 首屏快照拆分
打开会话时,snapshot() 只等待 get_state 与 get_messages 两个请求,拿到即可渲染对话。模型列表、思考级别、会话统计与已加载的 skill 由 emitSnapshotMeta() 在首屏之后并行请求,通过 desktop_snapshot_meta 事件补发给渲染进程。这些元数据请求失败也不影响已经完成的首屏。
5.4 进程树回收
Agent 运行过程中会派生 shell、沙箱和子 Agent。直接 child.kill() 只会结束 worker 本身,留下脱离的孤儿进程。因此 worker 以 detached 方式启动(Windows 除外),停止时按进程树发送信号:
// src/main/agent-host.ts(节选)
killProcessTree(child.pid, "SIGTERM");
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (child.exitCode === null && child.pid !== undefined) {
killProcessTree(child.pid, "SIGKILL");
}
resolve();
}, 2_000);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
先 SIGTERM 给 2 秒优雅退出时间,超时再 SIGKILL。worker 异常退出时同样会回收其进程树,并 reject 全部挂起的请求;向已关闭的 stdin 写入产生的 EPIPE 会被吞掉,避免主进程因此崩溃。
六、多会话路由与空闲回收
Tether 允许多个会话同时运行,每个会话对应一个 AgentHost,由 AgentHostManager 统一管理。这里的核心问题是:一条命令必须送达正确的会话。
因此 agent.command 显式携带 sessionPath。未携带时,getHost() 的策略是宁可不路由,也不猜测:
// src/main/agent-host-manager.ts(节选)
if (this.activeSessionPath) {
const activeHost = this.hosts.get(this.activeSessionPath);
if (activeHost) return activeHost;
// 活动会话已知但宿主已停止,回退到“任意运行中的宿主”会把命令送进无关对话
return undefined;
}
// 没有活动会话时,只有恰好一个运行中的宿主才是无歧义的
const running = [...new Set(this.hosts.values())].filter((host) => host.isRunning());
return running.length === 1 ? running[0] : undefined;
同一个宿主可能同时以临时 id 和真实会话路径两个键注册,所以统计前先用 Set 去重。
新会话在发出第一条消息前还没有会话文件,先以 tempId 注册;worker 返回会话文件路径后,通过 onSessionResolved 回调把映射改键为规范路径。
资源方面,每次新建宿主前执行 pruneIdleHosts():非活动、非忙碌的宿主闲置超过 10 分钟即停止;剩余空闲宿主按最近活跃时间排序,只保留 3 个。
七、Checkpoint 与 /undo
Agent 的文件修改全部以补丁形式执行,每次写入在会话中追加一条 tether-checkpoint 记录,before 字段保存写入前的内容;content 为 null 表示该文件原本不存在,恢复时应删除。
撤销时,渲染进程通过 get_entries 读取会话条目,由 lastTurnRestoreFiles 计算需要写回的文件:
// src/renderer/conversation.ts
export function lastTurnRestoreFiles(entries: SessionEntryLike[]): RestoreFile[] {
const undone = new Set<string>();
for (const entry of entries) {
if (entry.type !== "custom" || !isCheckpointUndo(entry.customType) || !isRecord(entry.data)) continue;
if (typeof entry.data.checkpointId === "string") undone.add(entry.data.checkpointId);
}
let start = 0;
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index]!;
if (entry.type !== "message" || entry.message?.role !== "user") continue;
if (entryUserText(entry.message.content).trim() === "/undo") continue;
start = index + 1;
}
const byPath = new Map<string, RestoreFile>();
for (let index = start; index < entries.length; index += 1) {
const entry = entries[index]!;
if (entry.type !== "custom" || !isCheckpoint(entry.customType) || !isRecord(entry.data)) continue;
if (typeof entry.data.id !== "string" || undone.has(entry.data.id) || !Array.isArray(entry.data.before)) continue;
for (const file of entry.data.before) {
if (!isRecord(file) || typeof file.path !== "string" || byPath.has(file.path)) continue;
if (file.content !== null && typeof file.content !== "string") continue;
byPath.set(file.path, {
path: file.path,
content: file.content,
...(typeof file.mode === "number" ? { mode: file.mode } : {}),
});
}
}
return [...byPath.values()];
}
算法包含三条规则:
- 跳过已撤销的 Checkpoint:带有
tether-checkpoint-undone标记的记录不再参与计算,连续执行/undo不会重复恢复。 - 以最后一条真实用户消息为轮次边界:内容为
/undo的消息本身不算边界。 - 同一路径只取最早的
before:一轮中被多次修改的文件,恢复到本轮开始前的状态,而不是某次中间修改。
计算结果经确认弹窗展示给用户,确认后通过 workspace:restore 写回磁盘(同样经过第四节的路径校验),界面侧再用 dropLastTurn 移除最后一轮消息。

八、权限模型
| 模式 | 行为 |
|---|---|
plan |
只读分析与规划;诊断命令可在只读沙箱中运行 |
ask |
写入、网络或越界操作前请求确认 |
auto |
自动执行工作区内的常规操作,越界时请求确认 |
full |
关闭工作区沙箱,仅用于明确授权的可信项目 |
macOS 上沙箱基于 Seatbelt 实现;Windows 提供实验性的 sandbox helper,需要单独安装启用。没有可用沙箱的平台上,启动项目会话前会弹窗请求直接读写授权,用户拒绝则取消启动。沙箱属于纵深防御,不能替代对 Agent 所执行命令的审查。
九、生成中的消息队列
模型生成期间,用户仍可继续输入。消息进入队列(上限 10 条,不写入会话文件),在当前轮次结束后按序发送。队列的调度条件被抽成纯函数,便于单独测试:
// src/renderer/queue.ts
export function dispatchBlock(input: {
running: boolean; loading: boolean; sending: boolean;
flushing: boolean; held: boolean; queued: number;
}): DispatchBlock | undefined {
if (input.running) return "running";
if (input.loading) return "loading";
if (input.sending) return "sending";
if (input.flushing) return "flushing";
if (input.held) return "held";
if (input.queued <= 0) return "empty";
return undefined;
}
export function queueHeldAfter(event: "stop" | "error" | "start" | "settle", interrupted: boolean): boolean {
if (event === "stop" || event === "error") return true;
if (event === "start") return false;
return interrupted;
}
queueHeldAfter 定义了暂停语义:用户主动停止或出错后队列暂停,不自动继续发送;新一轮开始或正常结束才解除暂停。/ 命令不进入队列;切换会话或项目时队列清空。
十、已知局限
以下问题已在仓库进度文档中记录,尚未解决:
- 错误字符串耦合:渲染层通过正则匹配主进程抛出的
"Agent session closed"判断停止态,因此该文案无法国际化。彻底解决需要改为错误码。 - 会话路径模糊匹配:
getHost(sessionPath)的有参分支在精确匹配失败后会按 basename 匹配,存在误配的可能。 - 主进程缺少测试:
src/main/index.ts依赖 Electron 运行时,目前没有单元测试,正确性依赖抽到 shared 层的纯函数测试。 - 渲染产物体积:renderer 单 chunk 约 540 kB(gzip 168 kB),尚未做代码分割。
- 文件监听:当前为递归
fs.watch加 200ms 防抖,在 Linux 或网络文件系统上可能丢事件,届时需要替换为 chokidar。
十一、总结
Tether 的设计重心不在 Agent 本身,而在 Agent 与用户机器之间的边界:渲染进程通过类型契约收口权限,主进程对每个文件路径做 realpath 级校验,Agent 以独立进程运行并按进程树回收,多会话路由在歧义时拒绝猜测,文件修改全部可按轮次撤销。Agent 能力复用 Pi 生态,桌面端把精力集中在这些边界和交互细节上。
欢迎在 GitHub 查看源码或提交 Issue。