)
把插件化 Agent 框架改造成跨平台桌面应用从架构设计、sidecar 载荷装配到 electron-builder 打包的完整工程实录微信公众原文地址一次真实的给 Agent 加一张桌面脸的改造记录。没有泛泛而谈的 Demo 贴图全部是在一个真实插件化 Agent 框架里落地跑通的工程细节Electron 壳与 sidecar 子进程如何分工、3.4 万个文件的多运行时载荷如何被装配进安装包、原生.node模块为什么必须待在 asar 之外、以及那些只有在真实机器上才会踩到的坑。Github仓库地址Gitee仓库地址AtomGit仓库地址安装包地址免安装绿色版地址为什么要把 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()抢占单实例锁第二个实例只负责把已开窗口拉起来预留一个空闲回环端口spawnsidecardsh --profile web --host 127.0.0.1 --port port主进程轮询POST /api/host.describe直到返回 2xx健康探测通过再loadURL加载该源窗口里跑的就是随包发布的 web UI和浏览器访问完全一致。用一张时序图概括BrowserWindowharness sidecardsh --profile webElectron 主进程BrowserWindowharness sidecardsh --profile webElectron 主进程抢占单实例锁、预留空闲端口spawn显式传入 --port启动 Cordis 插件树web-app bundle监听 127.0.0.1:port轮询 /api/host.describe 直到 2xxloadURL(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 需要的全部依赖pnpm--filterdsh-desktop-sidecar-runtime deploy\--legacy--prod--config.node-linkerhoisted\--config.auto-install-peersfalse --config.link-workspace-packagestrue\apps/desktop/.sidecar/app但依赖关系是会漂移的。因为auto-install-peersfalse那些以 peer 身份骑行的 Service Definition 包会被丢掉。应对办法是机械补全遍历每个已部署包的peerDependencies凡是在 workspace 里存在的就补进来注册表 peer 如react保持缺席因为浏览器 bundle 在apps/web/distnode 半边从不需要它们。原生模块必须放在 asar 之外node-pty、原生解压工具等.node模块和子进程运行时路径穿过 asar 虚拟文件系统会坏所以 sidecar 整个载荷作为extraResources放在 asar 外# apps/desktop/electron-builder.ymlextraResources:-from:.sidecarto:sidecarfilter:-**/*固定 Node 运行时并校验sidecar 自带一个固定版本的 Node跨平台下载后要做 sha256 校验再用载荷自己的 Node 跑dsh --version冒烟证明运行时 依赖图 入口三者一起是通的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 次重启失败才弹错误框放弃asyncfunctionhandleUnexpectedExit(code:number|null):Promisevoid{if(isQuitting())returnconsecutiveStartFailures1if(consecutiveStartFailuresMAX_CONSECUTIVE_START_FAILURES){fatal(the harness sidecar keeps crashing ...)return}awaitdelay(RESTART_DELAY_MS)awaitstartSidecar()mainWindow?.webContents.reload()}探测不阻塞退出用AbortController贯穿整个启动链路quit 一开始就 abort 掉在途的等待。核心难点三冷启动、杀软与安装后自启失败真实机器让const PROBE_TIMEOUT_MS 30_000现了形首次安装后勾选启动应用可能 30 秒内 sidecar 都起不来——因为文件刚从安装包解压杀毒软件正在实时扫描整个载荷冷启动 web 配置远慢于平常。于是把打包模式的探测预算也拉到和 dev 一致constPROBE_TIMEOUT_MS_DEV120_000constPROBE_TIMEOUT_MS_PACKAGED120_000更新注释时我也补了一句冷启动可能远超 30 秒——源码侧要经 tsx 把整个 profile 跑热刚装好的打包载荷还要过一遍实时杀软扫描。核心难点四跨平台打包目标用一份配置覆盖主流平台。这里有个反直觉但重要的点sidecar 的原生模块是按主机构架编译的所以 arm64 的安装包必须在 arm64 机器上重新组装载荷再打包不能指望在一台 x64 上一次全出。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的探测预算忘了重新assembleweb 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 packageagent-loop、持久化、审批流程原样复用。得到的是一个可双击运行、带单实例锁、崩溃自愈的桌面窗口跨平台安装包NSIS / dmg / AppImage自带 Node 运行时无需用户装 Node会话数据与 CLI 共享同一份~/.dsh命令行和桌面入口指向同一个产品。免安装绿色版写在最后给 Agent 加一张桌面的脸难点从来不在 Electron 本身而在如何让一个多运行时、多插件、几万个文件的应用以可复现、可校验、可安装的方式被分发出去。理解了 sidecar 载荷的物化与原生模块的边界你就把握住了这类壳 子进程架构的命门。如果你也在做 Agent / AI 工具的桌面化欢迎在评论区聊聊你更倾向浏览器里跑 web UI还是本地壳 sidecar或者留下你踩过最疼的那个坑我们一起挖。如果本文对你有帮助点赞 收藏是对我最大的鼓励也让我知道这类工程化落地的内容是不是你想要的。