avatar

mdo

Hello

  • 首页
  • 知识库
  • 归档
  • 标签
  • 关于
主页 基于 Pi,我居然自己搞了一个好用的 AI Agent 桌面工作台先说结论:别重复造轮子 很多人一听"自己开发 Agen - 掘金
文章

基于 Pi,我居然自己搞了一个好用的 AI Agent 桌面工作台先说结论:别重复造轮子 很多人一听"自己开发 Agen - 掘金

发表于 25天前 更新于 25天前
作者 mdo
20~26 分钟 阅读

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

官网:tether-code.xyz

先说结论:别重复造轮子

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

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

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

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

架构长什么样

一句话总结架构:

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 只是它的一个机械实现:

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 层就是纯翻译,不掺任何逻辑:

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、处理超时:

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");

  }

  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) };
  }
}

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

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

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

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

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

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

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

export interface RestoreFile {
  path: string;
  content: string | null;  
}

export function lastTurnRestoreFiles(entries: SessionEntryLike[]): RestoreFile[] {

}

export function dropLastTurn(messages: ChatMessage[]): ChatMessage[] {

}

配合主进程的 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-act、init-long-run、continue-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 开发坑。

知识库
工作台 自己 好用
许可协议:  CC BY 4.0
分享

相关文章

10月 7, 2026

比尔盖茨的智慧:想多赚钱,就每天循环做这3件事

我们总有一种非常固执的错觉,认为那些站在财富金字塔顶端的人,必然拥有某种超越常人的特异功能。我们在脑海中给比尔盖茨这样的大佬描绘了一幅极其悲壮的奋斗画像。大家总觉得,他之所以能富可敌国,肯定是因为他每天只睡三个小时,同时对着八个电脑屏幕疯狂敲击键盘,每一秒钟都在做出价值几亿美金的生死抉择。 为了模仿

9月 28, 2026

程序员越想创业,越不要急着动手

一个能源方面的前辈找到我,希望通过我把一些人工的工作 AI 自动化。 能源方面我不懂,找 Gemini 聊完发现这个是可以复制的,非常兴奋。我跟老婆说,这个项目做好以后可以做成平台,推广到其他公司,你就等着做总裁夫人吧! 她听完以后跟我说,这个项目还是太定制化,和我之前做的一个项目很像。 那个项目一

9月 25, 2026

忍了一年多,我终于对i18n下手了过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道

前言 大家好,我是奈德丽。 过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。 痛点背景 先说说我们在机票项目中遇到的

下一篇

Cursor中使用Skills

上一篇

github Laravel Octane 项目

最近更新

  • 比尔盖茨的智慧:想多赚钱,就每天循环做这3件事
  • iOS 侧载(Sideloading)工具
  • 程序员越想创业,越不要急着动手
  • 注册 Chat Participant
  • 中秋给在外游子的一封信

热门标签

API CodeGeex Coding Cursor DeepSeek Docker Gitkraken Harness Laravel Management

目录

©2026 mdo. 保留部分权利。