AX 爱鲜报

综合 / 开发工具 · 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 宿主、多会话路由、撤销机制与消息队列。

image.png

二、整体架构

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

分层遵循两条原则:

  1. 渲染进程零特权。BrowserWindow 开启 contextIsolation 与 sandbox,关闭 nodeIntegration,所有桌面能力都必须经过类型化的 IPC 契约。
  2. Agent 与 UI 生命周期解耦。Agent 运行在独立子进程中,通过 process.execPath 配合 ELECTRON_RUN_AS_NODE=1 启动。Agent 崩溃不会影响窗口;已落盘的会话可继续作为对话恢复,但不会自动重放未完成的命令。

image.png

三、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,它做三层校验:

  1. 工作区白名单:目标根目录必须是当前 Agent 的 cwd,或在最近打开的工作区列表中。
  2. 词法校验:path.resolve 后必须位于根目录之内,拦截 ../ 穿越。
  3. 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 移除最后一轮消息。

image.png

八、权限模型

模式 行为
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。

标签 AI Agent TypeScript Electron 架构

评论

登录后才可评论

0 条评论

  • 暂无评论,来写第一条吧。