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 开工。

用户需要提供或确认这些信息:

  1. 项目:Agent 要修改哪个代码仓库。通常由用户当前所在的项目页面自动带入,不必重复选择。
  2. Task name:这条开发支线的名称。它是创建 Task 的必填项;Emdash 可以根据 issue、PR 或随机规则自动生成,用户也可以修改。
  3. Workspace Settings :代码在哪里改。默认是在项目中创建独立 worktree;用户也可以选择仓库根目录、已有 workspace、PR 分支或远程 sandbox。创建新 worktree 时,还可确认:
    • 从哪个分支开始;
    • 是直接 checkout 该分支,还是基于它创建新分支;
    • 新分支名称;
    • 是否把新分支 push 到远端。
  4. Based on(可选):关联一个 Issue 或 Pull Request。Issue 可以来自 Linear、GitHub、Jira 等集成;选中后,标题和描述可以用于生成 Task name,并作为上下文交给 Agent。
  5. 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 后,系统自动完成:

  1. 将 Task、workspace 配置及可选的 initial conversation 写入 SQLite;
  2. 根据 Workspace Settings 创建或复用目录,并执行建分支、checkout、创建 worktree 等 Git 操作;
  3. 如果项目本身通过 SSH 连接,便在远程机器上准备 workspace;本地项目则在本机准备;
  4. 如果配置了 Initial Conversation,在准备好的 workspace 路径中启动所选 Agent:传统 CLI 走 PTY,支持 ACP 且开启 Chat UI 时走 ACP;
  5. 将用户 Prompt 与可选的 issue 上下文发送给 Agent,持续接收状态和输出。

所以这里的"委派"不是 Agent 自己领取任务,也不是一个 Agent 再拆给其他 Agent,而是用户明确指定 在哪份代码上工作、由哪个 Agent 执行、要它做什么;Emdash 负责准备隔离环境、启动进程和汇总结果。

痛点与解决的问题

emdash 的多 agent 不是为了让 agent 们合力完成一件大事,而是为了"多路并行押注 + 隔离防污染"

真实痛点:在同一个仓库里同时跑多个 CLI agent 会互相覆盖文件、污染分支、终端输出混杂。所以"多 agent"要解决的是:

  • 并行提效:任务 A 修 bug、任务 B 做 feature,同时进行不干扰
  • 赛马择优:同一个问题让不同 agent/不同方案各跑一版,挑最好的 merge

核心实现复现思路

整体结构:
#mermaid-svg-WAcqWvUldxPJiEIS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WAcqWvUldxPJiEIS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WAcqWvUldxPJiEIS .error-icon{fill:#552222;}#mermaid-svg-WAcqWvUldxPJiEIS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WAcqWvUldxPJiEIS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WAcqWvUldxPJiEIS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WAcqWvUldxPJiEIS .marker.cross{stroke:#333333;}#mermaid-svg-WAcqWvUldxPJiEIS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WAcqWvUldxPJiEIS p{margin:0;}#mermaid-svg-WAcqWvUldxPJiEIS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WAcqWvUldxPJiEIS .cluster-label text{fill:#333;}#mermaid-svg-WAcqWvUldxPJiEIS .cluster-label span{color:#333;}#mermaid-svg-WAcqWvUldxPJiEIS .cluster-label span p{background-color:transparent;}#mermaid-svg-WAcqWvUldxPJiEIS .label text,#mermaid-svg-WAcqWvUldxPJiEIS span{fill:#333;color:#333;}#mermaid-svg-WAcqWvUldxPJiEIS .node rect,#mermaid-svg-WAcqWvUldxPJiEIS .node circle,#mermaid-svg-WAcqWvUldxPJiEIS .node ellipse,#mermaid-svg-WAcqWvUldxPJiEIS .node polygon,#mermaid-svg-WAcqWvUldxPJiEIS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WAcqWvUldxPJiEIS .rough-node .label text,#mermaid-svg-WAcqWvUldxPJiEIS .node .label text,#mermaid-svg-WAcqWvUldxPJiEIS .image-shape .label,#mermaid-svg-WAcqWvUldxPJiEIS .icon-shape .label{text-anchor:middle;}#mermaid-svg-WAcqWvUldxPJiEIS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WAcqWvUldxPJiEIS .rough-node .label,#mermaid-svg-WAcqWvUldxPJiEIS .node .label,#mermaid-svg-WAcqWvUldxPJiEIS .image-shape .label,#mermaid-svg-WAcqWvUldxPJiEIS .icon-shape .label{text-align:center;}#mermaid-svg-WAcqWvUldxPJiEIS .node.clickable{cursor:pointer;}#mermaid-svg-WAcqWvUldxPJiEIS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WAcqWvUldxPJiEIS .arrowheadPath{fill:#333333;}#mermaid-svg-WAcqWvUldxPJiEIS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WAcqWvUldxPJiEIS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WAcqWvUldxPJiEIS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WAcqWvUldxPJiEIS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WAcqWvUldxPJiEIS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WAcqWvUldxPJiEIS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WAcqWvUldxPJiEIS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WAcqWvUldxPJiEIS .cluster text{fill:#333;}#mermaid-svg-WAcqWvUldxPJiEIS .cluster span{color:#333;}#mermaid-svg-WAcqWvUldxPJiEIS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WAcqWvUldxPJiEIS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WAcqWvUldxPJiEIS rect.text{fill:none;stroke-width:0;}#mermaid-svg-WAcqWvUldxPJiEIS .icon-shape,#mermaid-svg-WAcqWvUldxPJiEIS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WAcqWvUldxPJiEIS .icon-shape p,#mermaid-svg-WAcqWvUldxPJiEIS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WAcqWvUldxPJiEIS .icon-shape .label rect,#mermaid-svg-WAcqWvUldxPJiEIS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WAcqWvUldxPJiEIS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WAcqWvUldxPJiEIS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WAcqWvUldxPJiEIS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 远程/本地 workspace-server 守护进程
Electron 主进程
Preload 独木桥
Renderer 浏览器进程
wire over socket/stdio
chat-ui / ui React
wire client
contextBridge
rpc.ts controller
main/core 40+ 域模块
SQLite drizzle
api/controller
acp host
pty
git/files

如果要从零复刻 Emdash,其核心思路如下:

  1. 第一步:基于 Git Worktree 的物理隔离(任务底座)
    • 思路:绝不能让所有 Agent 都在同一个目录下运行。当用户创建一个 Task 时,系统应自动为该任务分配一个独立的物理路径。
    • 实现 :在 Electron 主进程(Main)中操作 Git CLI,为项目创建一个隔离的 Worktree。同时在本地 SQLite 数据库中写入 tasksworkspaces 记录,确保任务状态(即使应用重启)能被持久化恢复。
  2. 第二步:双轨制的 Agent 运行时抽象(执行引擎)
    • 思路:需要兼容"传统纯命令行 Agent"和"现代结构化 Agent"。
    • 实现 (均在主进程中运行,避免阻塞 UI):
      • PTY 模式 :针对传统 CLI,通过 node-pty 在对应的 Worktree 路径下 spawn 子进程,注入环境变量(hooks)来劫持其行为,并处理终端字符流。
      • ACP 模式 :针对支持 Agent Client Protocol 的现代 Agent,通过 SessionManagerSessionCell 维护强类型的状态机,解析意图并管理权限与日志。

第三步:远程代理(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)。

  1. 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 等),将文件系统操作或终端环境外包给客户端。
  2. 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(能力协商)→ newSessionprompt(发指令)→ SessionUpdate(流式回状态)→ requestPermission(回调审批)。

把这个套到 A2A 上完全成立:一个 orchestrator agent 只要扮演 ACP 里的 Client 角色 ,把 sub-agent 当成被调用的 Agent,prompt 就是子任务,SessionUpdate 就是子任务进度。所以传输层、会话生命周期、流式回传这套东西是可复用的

但是语义层需要扩展------去掉"人在回路"的非对称假设,补上对等角色、agent 能力发现、远程寻址。

所以业界把它们做成两个协议,不是因为流程不同,而是因为**"另一端是不是人"这个假设不同**,导致权限、能力词汇、发现机制三处必须重做。

复用会断掉的 4 个点

分歧不在流程,而在 ACP 内置了"一端是人机 UI,另一端是干活的 Agent "这个非对称假设

  1. 角色对称性 :ACP 里 Client 天然带"人类能力"(弹权限框、渲染 UI),Agent 带"计算能力"。A2A 两端都是 Agent,是对等的------ACP 的角色划分在这里失效。
  2. 权限模型 :ACP 的 requestPermission 本质假设背后有个人点"允许/拒绝"。A2A 里 sub-agent 申请权限时,对面是另一个 Agent,没有人------这个回调要么退化成自动批准,要么得往上层再转发一跳,语义变了。
  3. 能力语义 :ACP 协商的是面向人的能力 (readTextFile、terminal、permission UI)。A2A 需要的是面向 agent 的能力------任务委派、能力发现("你会干什么")、Agent Card、技能广告、产物(artifact)交付。这批词汇 ACP 里没有。
  4. 寻址与发现 :ACP 是本地 的(一个 host 通过 stdio spawn 一个 CLI 子进程,点对点)。Google 的 A2A 强调跨网络、跨厂商发现(HTTP + Agent Card),要解决"我怎么找到并信任一个远程 agent",这是 ACP 完全没覆盖的层。
相关推荐
甘露s1 小时前
Eino DeepAgent 详解:让 Agent 从工具调用走向复杂任务执行
ai·agent·deepagent
BBsays1 小时前
AI 数字人合规安全完全手册(2026年8月最新版)
人工智能
AI服务老曹1 小时前
NVIDIA GPU部署AI视频分析常见问题和排查清单
人工智能·音视频
深圳市快瞳科技有限公司1 小时前
个体识别、行为解读、健康管理:多模态宠物AI大模型的场景化落地
人工智能·算法·计算机视觉·大模型·多模态·宠物·宠物ai识别
安逸sgr2 小时前
AI 编程工具在真实项目中适合做什么?不适合做什么?
人工智能·ai·大模型·agent·智能体
stevenzqzq2 小时前
opencode设置项
人工智能
liulilittle2 小时前
归一化:激活函数
人工智能·算法·机器学习·llm
fthux2 小时前
装闭 RenoPit 源码解析(02):AI装修闭坑系统数据库与模型设计
人工智能·ai·开源·github·open source·renopit
2601_956456342 小时前
AI巡检机器人实战评测:3款室外产品算法自研能力与真实场景表现横评(2026)
大数据·人工智能·机器人