把插件化 Agent 框架改造成跨平台桌面应用:从架构设计、sidecar 载荷装配到 electron-builder 打包的完整工程实录
一次真实的"给 Agent 加一张桌面脸"的改造记录。没有泛泛而谈的 Demo 贴图,全部是在一个真实插件化 Agent 框架里落地跑通的工程细节:Electron 壳与 sidecar 子进程如何分工、3.4 万个文件的多运行时载荷如何被装配进安装包、原生
.node模块为什么必须待在 asar 之外、以及那些只有在真实机器上才会踩到的坑。

为什么要把 Agent 框架做成桌面应用
我手上维护着一个插件化 Agent 框架 :它是一个很大的 monorepo,几十个能力包按「Service Definition / Provider / Consumer」能力缝拆分------shell、文件系统、终端、子代理、web 搜索、会话持久化......核心就是一句话:everything is a plugin。命令行(CLI)和浏览器版(web UI)都已经是跑通的产品。
但「命令行能用」和「做成一个普通用户愿意装的产品」之间,隔着最后一公里:
- 命令行有门槛,多数人需要的是一个窗口;
- 已有 web UI 是成熟的,重新写一层前端是重复造轮子;
- 但把浏览器页面"包"进桌面窗口,又不能动那些已经跑得很稳的分层。
于是目标很明确:不改任何现有 package,用最薄的壳,把一个已有的 web UI 变成桌面产品。
整体方案:一个壳 + 一个 sidecar 子进程,本地回环通信
目标形态只有两个角色:Electron 壳 负责窗口、进程与分发;harness sidecar 负责其余一切。前端不复制第二份------桌面窗口加载的就是
apps/web的构建产物,由 sidecar 的 webserver 提供。

整条链路如下:
- 主进程
requestSingleInstanceLock()抢占单实例锁,第二个实例只负责把已开窗口拉起来; - 预留一个空闲回环端口 ,
spawnsidecar:dsh --profile web --host 127.0.0.1 --port <port>; - 主进程轮询
POST /api/host.describe直到返回 2xx(健康探测通过 ),再loadURL加载该源; - 窗口里跑的就是随包发布的 web UI,和浏览器访问完全一致。
用一张时序图概括:
BrowserWindow harness sidecar(dsh --profile web) Electron 主进程 BrowserWindow harness sidecar(dsh --profile web) Electron 主进程 #mermaid-svg-YbmThuGtphfxTqXL{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-YbmThuGtphfxTqXL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YbmThuGtphfxTqXL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YbmThuGtphfxTqXL .error-icon{fill:#552222;}#mermaid-svg-YbmThuGtphfxTqXL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YbmThuGtphfxTqXL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YbmThuGtphfxTqXL .marker.cross{stroke:#333333;}#mermaid-svg-YbmThuGtphfxTqXL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YbmThuGtphfxTqXL p{margin:0;}#mermaid-svg-YbmThuGtphfxTqXL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YbmThuGtphfxTqXL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YbmThuGtphfxTqXL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .sequenceNumber{fill:white;}#mermaid-svg-YbmThuGtphfxTqXL #sequencenumber{fill:#333;}#mermaid-svg-YbmThuGtphfxTqXL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .messageText{fill:#333;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL .labelText,#mermaid-svg-YbmThuGtphfxTqXL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .loopText,#mermaid-svg-YbmThuGtphfxTqXL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YbmThuGtphfxTqXL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YbmThuGtphfxTqXL .noteText,#mermaid-svg-YbmThuGtphfxTqXL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .actorPopupMenu{position:absolute;}#mermaid-svg-YbmThuGtphfxTqXL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-YbmThuGtphfxTqXL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL .actor-man circle,#mermaid-svg-YbmThuGtphfxTqXL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YbmThuGtphfxTqXL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 抢占单实例锁、预留空闲端口 spawn(显式传入 --port) 启动 Cordis 插件树(web-app bundle) 监听 127.0.0.1:port 轮询 /api/host.describe 直到 2xx loadURL(http://127.0.0.1:port) session.list / session.prompt 等 RPC 会话事件、审批、问答(WebSocket 下行)
服务端之所以能"随包发布、零改动",是因为 web UI 的 dist 通过 require.resolve 由 sidecar 提供------也就是说,web 前端必须打包进 sidecar 的依赖闭包,桌面窗口看的和网页看的是同一份产物。
核心难点一:怎么把"一个运行时 + 一个框架的一堆插件"装进安装包
这是整个改造里最硬核的部分。桌面版不是只发一个 Electron 壳就行,它要自缚 Node 运行时 + 完整的插件依赖闭包。
用 pnpm deploy 物化生产闭包
我们有一个"纯依赖部署根",用它拉出 sidecar 需要的全部依赖:
sh
pnpm --filter dsh-desktop-sidecar-runtime deploy \
--legacy --prod --config.node-linker=hoisted \
--config.auto-install-peers=false --config.link-workspace-packages=true \
apps/desktop/.sidecar/app
但依赖关系是会"漂移"的。 因为 auto-install-peers=false,那些以 peer 身份骑行的 Service Definition 包会被丢掉。应对办法是机械"补全":遍历每个已部署包的 peerDependencies,凡是在 workspace 里存在的就补进来(注册表 peer 如 react 保持缺席,因为浏览器 bundle 在 apps/web/dist,node 半边从不需要它们)。
原生模块必须放在 asar 之外
node-pty、原生解压工具等 .node 模块和子进程运行时路径,穿过 asar 虚拟文件系统会坏 ,所以 sidecar 整个载荷作为 extraResources 放在 asar 外:
yaml
# apps/desktop/electron-builder.yml
extraResources:
- from: .sidecar
to: sidecar
filter:
- "**/*"
固定 Node 运行时并校验
sidecar 自带一个固定版本的 Node,跨平台下载后要做 sha256 校验,再用载荷自己的 Node 跑 dsh --version 冒烟,证明运行时 + 依赖图 + 入口三者一起是通的:
sh
apps/desktop/.sidecar/node/node.exe \
apps/desktop/.sidecar/app/lib/bin.js --version
# => 0.1.0-rc.5
实测这一份载荷:app 约 247 MB / 3.2 万+ 文件,node 约 101 MB / 约 2000 文件,最终安装包 180 MB 出头。3.4 万个小文件这个数字,后面还会回来咬我们一口。

核心难点二:进程的生命周期管理
桌面壳要管好一个子进程的"生老病死":
- 优雅退出 :
before-quit先停 sidecar 再退。POSIX 走SIGTERM→SIGKILL阶梯;Windows 上因为 Node 把所有信号都映射成强杀TerminateProcess,改用整树taskkill /T /F------崩溃一致性由持久层(SQLite WAL)承担。 - 崩溃自动重启:sidecar 意外退出后,冷却 1 秒在同端口重启,然后重载窗口;会话历史在磁盘上,视图可重建。连续 3 次重启失败才弹错误框放弃:
ts
async function handleUnexpectedExit(code: number | null): Promise<void> {
if (isQuitting()) return
consecutiveStartFailures += 1
if (consecutiveStartFailures > MAX_CONSECUTIVE_START_FAILURES) {
fatal(`the harness sidecar keeps crashing ...`)
return
}
await delay(RESTART_DELAY_MS)
await startSidecar()
mainWindow?.webContents.reload()
}
- 探测不阻塞退出 :用
AbortController贯穿整个启动链路,quit 一开始就 abort 掉在途的等待。
核心难点三:冷启动、杀软与"安装后自启失败"
真实机器让 const PROBE_TIMEOUT_MS = 30_000 现了形:首次安装后勾选"启动应用",可能 30 秒内 sidecar 都起不来------因为文件刚从安装包解压,杀毒软件正在实时扫描整个载荷,冷启动 web 配置远慢于平常。于是把打包模式的探测预算也拉到和 dev 一致:
ts
const PROBE_TIMEOUT_MS_DEV = 120_000
const PROBE_TIMEOUT_MS_PACKAGED = 120_000
更新注释时我也补了一句:冷启动可能远超 30 秒------源码侧要经 tsx 把整个 profile 跑热,刚装好的打包载荷还要过一遍实时杀软扫描。

核心难点四:跨平台打包
目标用一份配置覆盖主流平台。这里有个反直觉但重要 的点:sidecar 的原生模块是按主机构架编译的,所以 arm64 的安装包必须在 arm64 机器上重新组装载荷再打包,不能指望在一台 x64 上"一次全出"。
yaml
win:
target: [nsis]
mac:
target: [dmg]
linux:
target: [AppImage, deb]
还有个容易栽的坑:每个平台下的 arch 字段其实是 defaultArch,只接受单个字符串 ,不支持 [x64, arm64] 这种数组------多架构要用 --x64 --arm64 命令行传参,一开始把数组写进去会直接 schema 校验失败。
踩坑实录(都是真实复盘的干货)
- Electron 的 ESM 顶层
await死锁 :Electron 要等 ESM 主模块求值完才发ready事件,一旦在模块顶层await app.whenReady()就永远等不到------必须包成显式boot()函数。 EXDEV: cross-device link not permitted:下载解压 Node 运行时不能塞系统临时目录,要把工作目录建在 staging 内部,靠同卷rename落地。pnpm deploy会破坏工作区状态 :legacy deploy 会把node_modules标记成"待生产修复",下次pnpm run会把 devDependencies 剪掉。于是组装脚本在finally里跑一次pnpm install复原开发环境。readFileSync is not defined:脚本里少导了个node:fs导入,纯环境问题。- 编译产物没重装 :改了
main.ts的探测预算,忘了重新assemble,web dist 的 favicon 没进安装包------因为前端是从依赖闭包里require.resolve出来的,必须重新汇编,提醒自己"改前端 = 要重装配,不只是一条 dist"。 - Windows 上
git add慢 :在一个有几十万文件(node_modules、.pnpm-store、.sidecar)的仓库里天然慢,不是配错了 .gitignore。
目录结构:原则是"壳只管壳该管的"
apps/desktop/
├── src/
│ ├── main.ts # 单实例、端口预留、spawn、崩溃重启、优雅退出
│ └── sidecar/
│ ├── controller.ts # 子进程控制器(spawn/stop)
│ ├── paths.ts # dev 源码入口 vs 打包载荷的 launch plan
│ ├── ports.ts # 空闲端口
│ └── probe.ts # 健康探测
├── scripts/assemble-sidecar.mjs # 载荷装配:deploy + peer 补全 + Node 运行时
├── electron-builder.yml # 跨平台打包配置
└── sidecar-runtime/ # 纯依赖部署根

效果与收益
一次"零侵入"的改造:没有改动任何现有 capability package,agent-loop、持久化、审批流程原样复用。得到的是:
- 一个可双击运行、带单实例锁、崩溃自愈的桌面窗口;
- 跨平台安装包(NSIS / dmg / AppImage),自带 Node 运行时,无需用户装 Node;
- 会话数据与 CLI 共享同一份
~/.dsh,命令行和桌面入口指向同一个产品。




免安装绿色版

写在最后
给 Agent 加一张"桌面的脸",难点从来不在 Electron 本身,而在如何让一个多运行时、多插件、几万个文件的应用,以可复现、可校验、可安装的方式被分发出去。理解了 sidecar 载荷的物化与原生模块的边界,你就把握住了这类"壳 + 子进程"架构的命门。
如果你也在做 Agent / AI 工具的桌面化,欢迎在评论区聊聊:你更倾向"浏览器里跑 web UI"还是"本地壳 + sidecar"? 或者留下你踩过最疼的那个坑,我们一起挖。
如果本文对你有帮助,点赞 + 收藏是对我最大的鼓励,也让我知道这类"工程化落地"的内容是不是你想要的。