写在前面:这篇文章不是什么商业软文,就是我个人折腾的一个记录。起因很简单------市面上的 AI 编程工具要么是云端黑盒,要么想改点东西都无从下手。所以我就想,能不能基于一个开源的 agent 生态,自己攒一个本地优先、看得见摸得着的 Agent 工作台?于是就有了下面这个东西。
先说结论:别重复造轮子
很多人一听"自己开发 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) };
}
}
有个细节: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 就能恢复上一轮的改动。
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-act、init-long-run、continue-long-run 这一组。写 agent 应用最大的心得之一:把团队约定变成 skill,而不是靠聊天记录口口相传。
踩过的坑 / 心得
- 别把 Electron 当浏览器。渲染进程和主进程的边界如果一开始不划清楚,后面每个功能都在跟安全配置搏斗。
- agent 进程必须独立于 UI 生命周期。一开始我也想过放主进程里,后来发现一个崩溃的 agent 能把整个应用带走,独立子进程 + 磁盘会话是唯一靠谱的组合。
- 超时是 RPC 的第一公民。没有白名单式的超时管理,长任务和短任务混在一起,迟早有一天 UI 全卡。
- 补丁优于整体写入。让 AI 每次写整个文件太危险,patch + checkpoint 才能让"撤销"成为可能。
代码在 GitHub:github.com/tt-11-dd/te...,MIT 协议,欢迎来看。
最后
说实话,从"想改个 AI 工具都改不动"到"自己攒了一个顺手的工作台",中间最值钱的不是代码量,而是想清楚哪些东西该复用生态、哪些东西必须自己做。Agent 生态还在快速演进,站在巨人的肩膀上,把产品边界和用户体验打磨好,可能比什么都从零写更有价值。
如果这篇文章对你有启发,或者你想聊聊 Agent 应用架构,评论区见。
如果你觉得有用,欢迎 Star / 转发;也欢迎在评论区交流你踩过的 Agent 开发坑。