Emdash 拆解:多 Agent 并行开发桌面端的实现思路,兼谈 ACP 与 A2A
Em dash是排版术语中的“破折号”(
—),长度等于一个字母 “m”(em)的宽度。破折号通常用来引出解释、插入独立的想法,或者连接两个相关的子句。在这个项目里,它隐喻了产品的核心形态——为主线开发流程“开辟一条支线”(独立的 Git worktree),让各个 AI Agent 在这些支线里独立、并行地执行任务,最后再把结果无缝“连接”回主分支(PR/Diff)。
这个项目是一个本地优先的桌面控制台,核心能力(源自 README “What You Can Do”):
- 同时跑多个 coding agent,不用手动切多个终端
- 每个 agent 关在独立 Git worktree + 独立分支里隔离
- 把 Linear/GitHub/Jira/GitLab 等的issue/ticket 直接派给某个 agent
- 在一处看 diff、建 PR、查 CI、合并
- 本地或 SSH 远程机器上跑同一套流程
用户如何把任务交给 Agent
Emdash 把“创建工作区”和“启动 Agent”放在同一个Create Task窗口里,但两者不是一回事:用户可以只创建一个隔离的 Task,之后再启动 Agent;也可以同时配置Initial Conversation,创建后立即让 Agent 开工。
用户需要提供或确认这些信息:
- 项目:Agent 要修改哪个代码仓库。通常由用户当前所在的项目页面自动带入,不必重复选择。
- Task name:这条开发支线的名称。它是创建 Task 的必填项;Emdash 可以根据 issue、PR 或随机规则自动生成,用户也可以修改。
- Workspace Settings:代码在哪里改。默认是在项目中创建独立 worktree;用户也可以选择仓库根目录、已有 workspace、PR 分支或远程 sandbox。创建新 worktree 时,还可确认:
- 从哪个分支开始;
- 是直接 checkout 该分支,还是基于它创建新分支;
- 新分支名称;
- 是否把新分支 push 到远端。
- Based on(可选):关联一个 Issue 或 Pull Request。Issue 可以来自 Linear、GitHub、Jira 等集成;选中后,标题和描述可以用于生成 Task name,并作为上下文交给 Agent。
- Initial Conversation(可选):
- Agent:选择 Claude Code、Codex、OpenCode 等已安装并被 Emdash 检测到的 provider;通常会预选默认 Agent。
- Prompt:描述希望 Agent 做什么,例如“修复登录超时并补充回归测试”。可以留空,也可以插入 Prompt Library 模板、文件或 issue 上下文。
- Model:如果该 Agent 暴露了模型列表,可以指定模型;不选则使用 CLI 默认模型。
- Auto-approve permissions:是否自动批准 Agent 的权限请求。
- Use chat UI:Agent 支持 ACP 时,可选择结构化聊天界面;关闭时走传统 PTY 终端。
真正的创建条件只有:项目存在、Task name 非空、Workspace 配置合法。Agent 和 Prompt 都不是必填项;没有选择 Agent 时,Emdash 只创建 Task 和 workspace,不创建初始 conversation。
点击Create后,系统自动完成:
- 将 Task、workspace 配置及可选的 initial conversation 写入 SQLite;
- 根据 Workspace Settings 创建或复用目录,并执行建分支、checkout、创建 worktree 等 Git 操作;
- 如果项目本身通过 SSH 连接,便在远程机器上准备 workspace;本地项目则在本机准备;
- 如果配置了 Initial Conversation,在准备好的 workspace 路径中启动所选 Agent:传统 CLI 走 PTY,支持 ACP 且开启 Chat UI 时走 ACP;
- 将用户 Prompt 与可选的 issue 上下文发送给 Agent,持续接收状态和输出。
所以这里的“委派”不是 Agent 自己领取任务,也不是一个 Agent 再拆给其他 Agent,而是用户明确指定在哪份代码上工作、由哪个 Agent 执行、要它做什么;Emdash 负责准备隔离环境、启动进程和汇总结果。
痛点与解决的问题
emdash 的多 agent 不是为了让 agent 们合力完成一件大事,而是为了"多路并行押注 + 隔离防污染"。
真实痛点:在同一个仓库里同时跑多个 CLI agent 会互相覆盖文件、污染分支、终端输出混杂。所以"多 agent"要解决的是:
- 并行提效:任务 A 修 bug、任务 B 做 feature,同时进行不干扰
- 赛马择优:同一个问题让不同 agent/不同方案各跑一版,挑最好的 merge
核心实现复现思路
整体结构:
如果要从零复刻 Emdash,其核心思路如下:
- 第一步:基于 Git Worktree 的物理隔离(任务底座)
- 思路:绝不能让所有 Agent 都在同一个目录下运行。当用户创建一个 Task 时,系统应自动为该任务分配一个独立的物理路径。
- 实现:在 Electron 主进程(Main)中操作 Git CLI,为项目创建一个隔离的 Worktree。同时在本地 SQLite 数据库中写入
tasks和workspaces记录,确保任务状态(即使应用重启)能被持久化恢复。
- 第二步:双轨制的 Agent 运行时抽象(执行引擎)
- 思路:需要兼容“传统纯命令行 Agent”和“现代结构化 Agent”。
- 实现(均在主进程中运行,避免阻塞 UI):
- PTY 模式:针对传统 CLI,通过
node-pty在对应的 Worktree 路径下 spawn 子进程,注入环境变量(hooks)来劫持其行为,并处理终端字符流。 - ACP 模式:针对支持 Agent Client Protocol 的现代 Agent,通过
SessionManager和SessionCell维护强类型的状态机,解析意图并管理权限与日志。
- PTY 模式:针对传统 CLI,通过
第三步:远程代理(workspace-server)
要解决的场景:你的项目代码不在本地 Mac,而在一台远程服务器上(通过 SSH 连的)。
桌面 App 想在远程跑 Agent、读文件、执行 git,最"笨"的做法是每个操作都开一条 SSH 命令发过去。但 Agent 干活是高频的(读几百个文件、频繁跑 git status),每次都新建 SSH 连接、启动进程,延迟高、开销大,还没法维持长会话状态。
Emdash 的做法:干脆在远程机器上常驻一个小程序(workspace-server,一个 Node 守护进程)。
- 桌面端只跟这个常驻程序说话,由它在远程本地就近执行 git/文件/Agent 操作。
- 通信走Unix Socket(本地进程间管道,SSH 转发过来),而不是每次开新 SSH 命令。
- 它们之间用一套固定格式的协议对话(Wire Protocol,标了版本号 v2.0.0,方便两端升级时对得上)。
一句话:把"每次远程喊话"变成"在远程派个常驻代表,你只跟代表沟通",代表就近帮你操作文件系统、git 和 Agent 进程。
第四步:展示层闭环(前端 UI)
要解决的问题:前端界面(你看到的聊天窗、diff 面板)要不要直接碰数据库、文件、子进程?
Electron 里前端是浏览器环境(Renderer 进程)。如果让它直接读 SQLite、spawn 进程,一旦前端代码有漏洞(比如渲染了恶意内容),攻击者就直接拿到了你机器的文件和 shell 权限。这是 Electron 安全的大忌。
Emdash 的做法:前端和后端彻底隔离,只留一个细窄的通道。
- 所有底层能力(DB、git、进程)都关在**主进程(Main)**里。
- 前端(Renderer)想要数据,必须通过
contextBridge(Preload 脚本暴露的一座"独木桥")发强类型 RPC 请求给主进程,主进程做完再把结果传回来。 - 前端自己只干两件事:渲染 UI+展示从数据库拉来的数据(比如聚合各任务的 PR 状态、diff、消息,做成审查面板)。
一句话:前端只当"显示器",所有危险操作都托管给主进程,中间靠一座受控的独木桥(Typed RPC)传话。
两步都是同一个设计哲学:上层永远不直接碰底层,中间用一个受控的代理/通道转发。这样既安全(第四步),又高效(第三步)。
ACP
全称:Agent Client Protocol(代码中对应@agentclientprotocol/sdk)。
- ACP(Agent Client Protocol)的大致内容是什么?
- 定位:ACP 是专门为了让宿主应用(如 Emdash 这种 GUI 控制台、IDE)和 AI Agent 之间进行结构化通信而设计的协议。
- 大致内容:它定义了一套基于 JSON-RPC(或类似结构)的双向通信标准。主要包括:
- Session Lifecycle(会话生命周期):
initialize,newSession,loadSession,closeSession。 - State & Transcript(状态与日志):Agent 通过
SessionUpdate(如思考中、发送消息、工具调用、计划更新)将内部状态流式推给客户端;客户端可以渲染出比纯文本终端更丰富的 UI(如带有折叠的思维链、清晰的工具调用卡片)。 - Permission Control(权限控制):Agent 要执行危险命令(如写文件、执行 Shell)时,必须通过
requestPermission向客户端申请,客户端可以弹出拦截框让用户“允许”或“拒绝”。 - Host Capabilities(宿主能力调用):Agent 可以反向调用客户端暴露的接口(如
readTextFile,writeTextFile,createTerminal等),将文件系统操作或终端环境外包给客户端。
- Session Lifecycle(会话生命周期):
- ACP 是抽象规则还是具体实现?
- 它类似 HTTP/IP 协议,本身是抽象规则(规范),定义了消息的 Schema 和交互时序。
- 但它有具体的 SDK 实现:在 Emdash 中,引用了
@agentclientprotocol/sdk这个包,它提供了 TypeScript 的强类型定义(如SessionNotification,RequestPermissionRequest)。 - Emdash 内部有一个专门的
acp-agents模块实现了这个协议的客户端(Host)端点,负责把原生的SessionUpdate转换(toAgentUpdate)成状态机(SessionMachine)可以消费的统一领域事件(NormalizedEvent)。
ACP 和 A2A(Agent-to-Agent)
流程层面 A2A 确实可以复用 ACP 的骨架,分歧不在"流程",而在几个隐含假设上。
ACP 的核心时序其实是一个通用的会话协议:initialize(能力协商)→newSession→prompt(发指令)→SessionUpdate(流式回状态)→requestPermission(回调审批)。
把这个套到 A2A 上完全成立:一个 orchestrator agent 只要扮演 ACP 里的Client 角色,把 sub-agent 当成被调用的 Agent,prompt就是子任务,SessionUpdate就是子任务进度。所以传输层、会话生命周期、流式回传这套东西是可复用的。
但是语义层需要扩展——去掉"人在回路"的非对称假设,补上对等角色、agent 能力发现、远程寻址。
所以业界把它们做成两个协议,不是因为流程不同,而是因为**"另一端是不是人"这个假设不同**,导致权限、能力词汇、发现机制三处必须重做。
复用会断掉的 4 个点
分歧不在流程,而在 ACP 内置了"一端是人机 UI,另一端是干活的 Agent"这个非对称假设:
- 角色对称性:ACP 里 Client 天然带"人类能力"(弹权限框、渲染 UI),Agent 带"计算能力"。A2A 两端都是 Agent,是对等的——ACP 的角色划分在这里失效。
- 权限模型:ACP 的
requestPermission本质假设背后有个人点"允许/拒绝"。A2A 里 sub-agent 申请权限时,对面是另一个 Agent,没有人——这个回调要么退化成自动批准,要么得往上层再转发一跳,语义变了。 - 能力语义:ACP 协商的是面向人的能力(readTextFile、terminal、permission UI)。A2A 需要的是面向 agent 的能力——任务委派、能力发现(“你会干什么”)、Agent Card、技能广告、产物(artifact)交付。这批词汇 ACP 里没有。
- 寻址与发现:ACP 是本地的(一个 host 通过 stdio spawn 一个 CLI 子进程,点对点)。Google 的 A2A 强调跨网络、跨厂商发现(HTTP + Agent Card),要解决"我怎么找到并信任一个远程 agent",这是 ACP 完全没覆盖的层。
