深度拆解 OpenCoWork:一个本地多智能体桌面平台的架构设计与实现
深度拆解 OpenCoWork:一个本地多智能体桌面平台的架构设计与实现
OpenCoWork 是一个开源桌面多智能体 AI 协作平台。它的定位不是再做一个聊天窗口,而是把大模型、文件系统、Shell、SSH、MCP、定时任务、办公 IM 插件和多 Agent 编排放进一个本地桌面运行时里,让 Agent 真正进入开发者的工作环境。
这篇文章不做功能清单罗列,重点拆它的技术实现:Electron 如何分层、Agent runtime 如何落地、工具系统如何注册与执行、Plan Mode 如何约束写文件、Cron Agent 如何后台运行、MCP 和办公消息插件又是怎样接入的。
项目地址:https://github.com/AIDotNet/OpenCoWork
本文基于当前代码结构分析,核心技术栈包括 Electron 36、React 19、TypeScript、Zustand、better-sqlite3、node-cron、MCP SDK、xterm.js、Monaco Editor 等。
1. OpenCoWork 解决的不是“聊天”,而是“本地执行”
传统 LLM 产品最明显的问题是环境割裂:代码在 IDE,日志在终端,需求在聊天软件,文件在本地目录,而 Agent 只能在浏览器里给建议。OpenCoWork 的架构目标很直接:让 Agent 可以在用户授权下访问本地上下文,并执行真实动作。
它可以读取代码、搜索文件、修改文件、跑命令、开 SSH、处理文档、调用 MCP 工具、给飞书/钉钉/微信/Telegram 等渠道发送结果。所以它的核心不是“模型对话 UI”,而是一个本地 Agent 操作系统雏形:
用户意图 -> 会话模式 -> Agent Runtime -> 工具系统 -> 本地/远程/插件能力 -> 结果回流 UI 或消息渠道
这也是它选择 Electron 的原因:既要有桌面 UI,又要拿到 Node.js 层的系统能力。
2. 四层 Electron 架构:把高权限能力关进主进程
OpenCoWork 采用典型但并不简单的四层 Electron 架构:
Renderer React UI
↓ ipcRenderer.invoke
Preload contextBridge
↓ ipcMain.handle
Main Process IPC / DB / FS / Shell / SSH / Channels / Cron
↓
Main-process Agent Runtime / MCP / Provider Adapter
对应代码非常清晰:
src/main/index.ts:Electron 主进程入口,负责窗口生命周期、IPC handler 注册、渠道插件注册、MCP、Cron、SSH、数据库关闭等。src/preload/index.ts:通过contextBridge.exposeInMainWorld暴露极少量 API。src/renderer/src/App.tsx:React 入口,初始化 provider、viewer、工具系统、插件监听、Agent stream 等。src/main/ipc/js-agent-runtime.ts与src/main/cron/cron-agent-background.ts:主进程侧 Agent runtime 与后台 Agent loop 的核心实现。
2.1 Main Process:所有危险能力都在这里
src/main/index.ts 里可以看到大量能力注册:
registerFsHandlers()
registerShellHandlers()
registerSettingsHandlers()
registerSkillsHandlers()
registerSshHandlers()
registerChannelHandlers(channelManager)
registerMcpHandlers(mcpManager)
registerCronHandlers()
registerBrowserHandlers()
registerGitHandlers()
registerTeamRuntimeHandlers()
registerTeamWorkerHandlers()
这意味着文件系统、Shell、SSH、数据库、MCP、Cron、消息插件等高权限能力都集中在主进程,通过 IPC 对渲染进程开放。
这样做的好处是边界明确:Renderer 不直接拿 Node 权限,用户界面和系统能力之间隔着 preload + IPC。风险也明显:main/index.ts 容易膨胀成“超级入口”。OpenCoWork 当前已经聚合了很多模块,后续如果继续增长,最好进一步拆成 domain service,例如 runtime-service、channel-service、mcp-service、job-service。
2.2 Preload:窄桥接,不把 Node 能力裸露出去
src/preload/index.ts 的核心就是:
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
它暴露的自定义 API 主要包括图片下载/读取/剪贴板、team runtime 创建/删除/快照/消息追加、isolated team worker 的启动/停止。
这种做法符合 Electron 安全设计:Renderer 不应直接访问 fs、child_process,而是通过受控 IPC 进入主进程。
3. 渲染器不是简单 UI,它负责工具目录和会话编排
OpenCoWork 的 Renderer 层不只是页面。src/renderer/src/App.tsx 在启动阶段会做几件关键事:
registerAllProviders()
registerAllViewers()
initProviderStore()
initAppPluginStore()
attachRendererToolBridge()
attachRendererProviderBridge()
agentStream.attach()
registerAllTools()
也就是说,前端层承担了模型 Provider 注册、预览器注册、工具系统注册、Agent stream 事件接收、运行时同步事件处理,以及 SubAgent / Team / Task / Plan 状态映射。
App.tsx 中对 runtime sync event 的处理也很关键,比如 task_add / task_update 同步任务面板,team_event / team_snapshot 同步团队运行态,subagent_event 同步子 Agent 状态,resolve_approval 处理工具审批结果。
这说明 OpenCoWork 的前端不是“薄 UI”,而是 Agent 工作台的状态中枢。
4. 工具系统:从统一注册到运行时上下文
工具系统入口在:
src/renderer/src/lib/tools/index.ts
registerAllTools() 的注册顺序很有代表性:
registerTaskTools()
registerFsTools()
registerSearchTools()
registerBashTools()
registerWidgetTools()
registerAskUserTools()
registerPlanTools()
registerCronTools()
registerNotifyTool()
registerGoalTools()
registerMemoryTools()
await refreshDynamicToolCatalog()
registerCodeCompatibleTools()
registerTeamTools()
这里可以看到 OpenCoWork 的工具分为几类:基础工具包括文件读写、搜索、Shell;协作工具包括任务、提问、计划、目标、记忆;自动化工具包括 Cron、Notify;动态工具包括 Skill、SubAgent、Wiki、WebSearch;兼容层工具面向 code-agent 风格 alias;Team 工具负责多 Agent 团队编排。
工具的统一接口在 src/renderer/src/lib/tools/tool-types.ts:
export interface ToolContext {
sessionId?: string
workingFolder?: string
sshConnectionId?: string
signal: AbortSignal
ipc: IPCClient
readFileHistory?: Map<string, FileReadSnapshot>
inlineToolHandlers?: Record<string, ToolHandler>
agentRunId?: string
pluginId?: string
pluginChatId?: string
sharedState?: { deliveryUsed?: boolean; bashCwd?: string }
}
export interface ToolHandler {
definition: ToolDefinition
execute: (input, ctx) => Promise<ToolResultContent>
requiresApproval?: (input, ctx) => boolean
}
这个设计抓住了 Agent 工具系统的关键:工具不是孤立函数,而是带上下文执行。上下文里包含当前会话、工作目录、SSH 连接、IPC 客户端、本轮已读文件快照、当前 Agent run id、插件消息来源和可变共享状态。
所以同一个 Read、Bash、Grep 工具,在本地会话、SSH 会话、Cron 后台任务、微信自动回复上下文里,可以有不同执行语义。
5. Agent Runtime:主进程里的统一执行循环
src/main/ipc/js-agent-runtime.ts 定义了 JsAgentRuntimeManager。它对外暴露类似 RPC 的方法:
case 'initialize'
case 'ping'
case 'shutdown'
case 'capabilities/check'
case 'agent/run'
case 'agent/append-messages'
case 'agent/cancel'
其中 agent/run 最终进入 startRun(),构造 runId、AbortController、RuntimeMessageQueue、ToolContext、renderer fallback tool executor 和 renderer approval probe。
关键点在这里:主进程 Runtime 可以自己执行后台能力,但遇到需要渲染器参与的工具、审批或 UI 状态时,会通过桥接回到 Renderer。
这是一种混合架构:
Main Runtime 负责 Agent loop 和系统执行
Renderer 负责工具目录、审批、UI 状态和部分工具 fallback
优点是灵活,兼容现有前端工具生态;缺点是边界更复杂,需要严格治理同名工具、审批逻辑和上下文同步。
6. Plan Mode:不是提示词约束,而是工具层硬约束
很多 Agent 产品所谓“计划模式”,本质只是提示词告诉模型“先别改代码”。这不可靠。
OpenCoWork 的 Plan Mode 做得更硬:它在工具层限制写入能力。核心文件是:
src/renderer/src/lib/tools/plan-tool.ts
关键逻辑是 createGuardedPlanFileHandler():
const currentPlanFilePath = getCurrentPlanFilePath(ctx)
const resolvedPath = resolveToolPath(input.file_path, ctx.workingFolder)
if (normalizeComparablePath(resolvedPath) !== normalizeComparablePath(currentPlanFilePath)) {
return encodeToolError(
`In plan mode, ${toolName} is restricted to the current plan file`
)
}
也就是说,在 Plan Mode 下,Write 和 Edit 被替换成 guarded handler,只允许改当前 .plan/<planId>.md 文件。
流程如下:
EnterPlanMode
-> 创建或恢复 plan
-> 在工作目录生成 .plan/<planId>.md
-> 开启 UI plan mode
-> 注入 inline tool handlers
Write/Edit
-> 检查目标路径是否等于当前 plan file
-> 不等则拒绝
ExitPlanMode
-> 读取 plan file
-> 提取标题
-> 状态改为 awaiting_review
-> 要求等待用户审核
这比单纯 prompt 约束可靠得多。因为限制落在工具执行层,而不是模型自觉层。
7. Cron Agent:定时任务不是提醒,而是后台 Agent 执行器
OpenCoWork 的 Cron 能力不只是“到点发通知”。它真正调度的是 Agent。
核心文件:
src/main/cron/cron-scheduler.ts
src/main/cron/cron-agent-background.ts
src/main/ipc/cron-handlers.ts
cron-scheduler.ts 中定义了三种 schedule:
schedule_kind: 'at' | 'every' | 'cron'
对应一次性定时、固定间隔和标准 cron 表达式。它还实现了并发保护:
let maxConcurrentRuns = 2
const activeRunJobIds = new Set<string>()更多推荐
所有评论(0)