基于 Pi,我居然自己搞了一个好用的 AI Agent 桌面工作台

写在前面:这篇文章不是什么商业软文,就是我个人折腾的一个记录。起因很简单------市面上的 AI 编程工具要么是云端黑盒,要么想改点东西都无从下手。所以我就想,能不能基于一个开源的 agent 生态,自己攒一个本地优先、看得见摸得着的 Agent 工作台?于是就有了下面这个东西。

官网:tether-code.xyz

先说结论:别重复造轮子

很多人一听"自己开发 Agent",第一反应是"那不得从模型调用、消息循环、工具协议、权限系统一路手写?"。

其实不用。Agent 领域这些年沉淀下来一批不错的开源生态,比如 Pi@earendil-works/pi-* 那套 npm 包)。它把 agent 循环、模型协议、coding-agent 扩展、RPC、TUI 组件这些都做好了。

我的思路很简单:站在 Pi 的肩膀上,把精力花在产品边界和体验上,而不是重新发明轮子。

最终做出来的东西叫 Tether------一个 Electron 桌面应用,模型调用、工作区工具、终端命令、权限提示、会话历史、diff 审查,全部收敛在一个本地工作台里。会话和配置数据都在你机器上,模型请求直连你配置的 provider,中间没有任何中转服务。

架构长什么样

一句话总结架构:

arduino 复制代码
React Renderer(会话 / diff / 设置 UI)
        │  contextBridge / Electron IPC
        ▼
Electron Main(窗口、工作区、凭据、Agent 进程宿主)
        │  JSON-RPC over stdio
        ▼
tether-agent-core(权限、沙箱、工具、checkpoint、MCP、会话)
        │
        ▼
Pi 生态(agent loop · 模型协议 · coding-agent 扩展 · RPC · TUI)

这里有个关键设计:渲染进程没有任何 Node.js 权限,桌面能力全部走类型化的 IPC 契约;Agent 跑在独立子进程里,崩溃了会话文件还在磁盘上,可以继续当对话恢复,但绝不静默重放没跑完的命令。

类型化的 IPC 契约,比想象中重要

Electron 里最常见的安全翻车就是 contextBridge 暴露了个大而全的对象。我这边反过来------先在 src/shared/types.ts 里定义好整个 DesktopApi 接口,preload 只是它的一个机械实现:

ts 复制代码
// src/shared/types.ts(节选)
export interface DesktopApi {
  workspace: {
    choose(): Promise<string | null>;
    recent(): Promise<WorkspaceItem[]>;
    read(path: string, cwd?: string): Promise<{ path: string; content: string; binary: boolean }>;
    open(path: string, cwd?: string): Promise<void>;
    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;
  };
  agent: {
    start(options: AgentStartOptions): Promise<AgentSnapshot>;
    stop(): Promise<void>;
    command<T = unknown>(type: string, data?: Record<string, unknown>): Promise<T>;
    onEvent(listener: (event: AgentEvent) => void): () => void;
  };
}

preload 层就是纯翻译,不掺任何逻辑:

ts 复制代码
// src/preload/index.ts(节选)
import { contextBridge, ipcRenderer } from "electron";
import type { AgentEvent, DesktopApi } from "../shared/types";

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 = {
  platform: process.platform,
  workspace: {
    choose: () => ipcRenderer.invoke("workspace:choose"),
    recent: () => ipcRenderer.invoke("workspace:recent"),
    read: (filePath, cwd) => ipcRenderer.invoke("workspace:read", filePath, cwd),
    restore: (files, cwd) => ipcRenderer.invoke("workspace:restore", files, cwd),
    onChanged: (listener) => subscribe<string>("workspace:changed", listener),
  },
  agent: {
    start: (options) => ipcRenderer.invoke("agent:start", options),
    stop: () => ipcRenderer.invoke("agent:stop"),
    command: (type, data) => ipcRenderer.invoke("agent:command", type, data),
    onEvent: (listener) => subscribe<AgentEvent>("agent:event", listener),
  },
};

contextBridge.exposeInMainWorld("harness", api);

好处很明显:渲染进程想碰文件系统?DesktopApi 里没这个方法,编译器直接报错。想绕过?contextIsolation 开着,nodeIntegration 关着,无路可走。安全边界不是靠自觉,是靠类型定义

Agent 进程宿主:一个带超时的 JSON-RPC 客户端

主进程里我写了个 AgentHost,负责 spawn agent 子进程、按行解析 JSON-RPC、处理超时:

ts 复制代码
// src/main/agent-host.ts(节选)
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",
]);

export class AgentHost {
  private child?: ChildProcessWithoutNullStreams;
  private pending = new Map<string, PendingRequest>();

  async start(options: AgentStartOptions & { cwd: string }): Promise<AgentSnapshot> {
    await this.stop();
    const args = [
      getTetherRpcEntryPath(),
      "--mode", "rpc",
      "--provider", options.provider,
      "--permission", options.permission,
      "--sandbox", options.sandbox,
    ];
    if (options.network) args.push("--network");
    // spawn 子进程,开始监听 stdout 行
  }

  async snapshot(): Promise<AgentSnapshot> {
    const [state, messages, models, thinkingLevels, stats, commands] = await Promise.all([
      this.request("get_state"),
      this.request("get_messages"),
      this.request("get_available_models"),
      this.request("get_available_thinking_levels"),
      this.request("get_session_stats").catch(() => undefined),
      this.request("get_commands").catch(() => ({ commands: [] })),
    ]);
    return { state, messages, models, thinkingLevels, stats, skills: parseSkillCommands(commands.commands) };
  }
}

有个细节:promptforkcompact 这类请求动辄跑几分钟,普通请求 45 秒超时。所以搞了个白名单------长任务用 30 分钟超时,其余保持短超时快速失败。别小看这个,没有超时控制的 RPC 客户端,挂一个请求,整个 UI 都得跟着卡死

权限模型:plan / ask / auto / full

Agent 能写文件、能跑命令,权限边界必须显式。四个模式:

模式 行为
plan 只读分析和规划,诊断命令跑在只读沙箱
ask 写操作、网络访问、边界升级之前先询问
auto 常规工作区操作自动执行,升级时询问
full 显式信任的项目关闭工作区沙箱

底层走 macOS Seatbelt 做沙箱,Windows 侧有一个实验性的沙箱辅助(要单独安装启用)。沙箱是纵深防御,不是让你不看命令的理由------在陌生仓库里该审的还得审。

补丁 checkpoint:让 /undo 真的能回滚

AI 改代码最怕的就是"它把文件改坏了"。我的方案是:所有文件变更都走补丁(patch)形式,每次写入打一个 checkpoint,UI 里一条 /undo 就能恢复上一轮的改动。

ts 复制代码
// src/renderer/conversation.ts(节选)
export interface RestoreFile {
  path: string;
  content: string | null;  // null 表示删除
}

export function lastTurnRestoreFiles(entries: SessionEntryLike[]): RestoreFile[] {
  // 从会话条目里找出上一轮所有的 checkpoint,组装成可恢复文件列表
}

export function dropLastTurn(messages: ChatMessage[]): ChatMessage[] {
  // 撤掉上一轮的渲染消息,配合 workspace.restore 一起用
}

配合主进程的 workspace:restore IPC,UI 上点了撤销,主进程把文件写回磁盘。这个机制看起来不起眼,实际用起来救命------尤其当你让 agent 重构完一个大文件觉得不对的时候

会话数据:纯本地、可继续

所有数据都落在 ~/.tether 下,无遥测、无中转。崩溃了也不怕:磁盘上的会话文件可以当作对话继续读,只是不会静默重放没执行完的命令------宁可让你手动重发,也不要假装一切正常

还做了几件小事:

  • @ 文件提及 :输入框里 @ 一下就能把项目文件引用进来,扫描范围限定在项目 .agents/skills.pi/skills
  • 生成中排队:回复还在生成时你可以继续打字回车,进队列(最多 5 条),当前轮结束按顺序发;停止生成会保留队列
  • 中英双语:i18n 做了 locale 切换,界面文案完全本地化

Skills / MCP / Hooks:不是摆样子

Pi 生态的 Skills 由运行时加载,Tether 不另写 loader,SKILL.md 带 frontmatter(name + description,缺项不加载)。MCP 和 Hooks 也都在 agent-core 里接好了,桌面壳这边负责把它们展示出来、把用户配置传下去。

项目自己的 skill 也是这么沉淀的,比如 UI 气质统一走 .agents/skills/tether-ui,长任务走 plan-then-actinit-long-runcontinue-long-run 这一组。写 agent 应用最大的心得之一:把团队约定变成 skill,而不是靠聊天记录口口相传。

踩过的坑 / 心得

  1. 别把 Electron 当浏览器。渲染进程和主进程的边界如果一开始不划清楚,后面每个功能都在跟安全配置搏斗。
  2. agent 进程必须独立于 UI 生命周期。一开始我也想过放主进程里,后来发现一个崩溃的 agent 能把整个应用带走,独立子进程 + 磁盘会话是唯一靠谱的组合。
  3. 超时是 RPC 的第一公民。没有白名单式的超时管理,长任务和短任务混在一起,迟早有一天 UI 全卡。
  4. 补丁优于整体写入。让 AI 每次写整个文件太危险,patch + checkpoint 才能让"撤销"成为可能。

代码在 GitHub:github.com/tt-11-dd/te...,MIT 协议,欢迎来看。

最后

说实话,从"想改个 AI 工具都改不动"到"自己攒了一个顺手的工作台",中间最值钱的不是代码量,而是想清楚哪些东西该复用生态、哪些东西必须自己做。Agent 生态还在快速演进,站在巨人的肩膀上,把产品边界和用户体验打磨好,可能比什么都从零写更有价值。

如果这篇文章对你有启发,或者你想聊聊 Agent 应用架构,评论区见。


如果你觉得有用,欢迎 Star / 转发;也欢迎在评论区交流你踩过的 Agent 开发坑。

相关推荐
世界哪有真情2 小时前
AI 写代码两年多,我发现自己越来越"看不进去"了
前端·后端·ai编程
全栈弄潮儿2 小时前
AI 写代码后,如何自己检查有没有问题?
aigc·openai·ai编程
禁止摆烂_才浅2 小时前
前端 AI 面试题
前端·面试·ai编程
小四的小六2 小时前
两个Agent同时写同一个Tool,数据被覆盖了——我是怎么用乐观锁修好的
openai·agent·ai编程
宋哥转AI2 小时前
深入理解 AI Agent · MEMORY #03:从设计到落地
人工智能·agent·ai编程
_codeOH2 小时前
大模型上下文窗口管理:从滑动窗口到 RAG
人工智能·ai编程
leeyi3 小时前
Langfuse 接入:给 Agent 加“行车记录仪“(第88篇-E74)
llm·aigc·agent
析数塔3 小时前
Meta 卖的不是模型,是你的代码
agent·ai编程
洞窝技术3 小时前
你的 Coding Agent 不是不够聪明,是被 git status 和测试日志淹死了|RTK 实战指南
aigc·ai编程