
1. 为什么我要给 Coding Agent 配一块实体看板用 Coding Agent 写代码这件事从去年下半年开始就彻底变了味。以前是我盯着它写现在是它自己跑一长串任务我隔十几分钟回来瞄一眼。问题也随之而来Agent 的待办列表活在它自己的上下文里我这边开着终端、编辑器、浏览器三四个窗口根本不知道它现在到底在干第几步、下一步要干嘛、有没有卡在某个环节反复重试。我试过在项目根目录放一个TODO.md让它自己读写。能用但体验很差。第一Markdown 文件是纯文本Agent 改完我看不出哪些是新加的、哪些是它自己划掉的第二我得手动打开文件才能看跟随时瞄一眼的诉求完全对不上第三多个 Agent 并行跑的时候同一个文件会被反复覆盖状态直接乱掉。所以我就想干脆做一个常驻桌面的小看板让 Coding Agent 通过 MCP 协议把待办事项推过来我这边实时看到。顺手还加了个像素桌宠任务完成的时候它会蹦一下算是给枯燥的等待过程加点反馈。整个项目开源代码量不大但踩的坑不少这篇就把设计思路、MCP 接入细节、桌面端实现和实际使用中的问题一次讲清楚。这个项目适合几类人参考一是天天跟 Coding Agent 打交道、想给它加个外置状态栏的开发者二是想入门 MCP 协议、找一个真实可跑的小项目练手的同学三是对桌面常驻小工具、像素风 UI 感兴趣想自己改一版的人。不需要你懂 Electron 或者 Tauri 的底层跟着走一遍就能跑起来。2. 整体架构与方案选型拆解2.1 核心需求到底有几个先把需求摊开不然后面选型全是拍脑袋。我列了四条硬需求实时性Agent 更新待办后看板要在 1 秒内反映出来不能靠手动刷新。双向可见Agent 能写我也能手动勾选、删除、加备注两边状态要一致。低打扰常驻桌面但不能抢焦点不能挡着代码最好能一键收起。可扩展以后想加任务耗时统计多 Agent 分栏这些功能架构上要留口子。这四条里最难的是第一条和第二条的组合。实时推送本身不难难的是两边都能改还不出冲突。我一开始想用文件监听chokidar那套Agent 改文件、我看板监听文件变化。实测下来问题很明显Agent 写文件不是原子的经常出现读到半截 JSON 的情况解析直接报错而且文件监听有防抖延迟快速连续更新会丢事件。2.2 为什么最终选了 MCP 本地 WebSocket绕了一圈最后定的方案是Coding Agent 通过 MCP 工具调用把待办推给一个本地服务本地服务再通过 WebSocket 广播给桌面看板。三层结构各管各的。为什么是 MCP因为现在主流的 Coding Agent 基本都支持 MCP 了这是事实上的标准接口。我不用为每个 Agent 单独写适配层只要实现一个 MCP Server暴露几个工具add_todo、update_todo、complete_todo、list_todos任何支持 MCP 的 Agent 都能直接接进来。这比让 Agent 去读写某个约定格式的文件要干净得多因为工具调用的参数是结构化的不存在解析半截 JSON 的问题。为什么中间要加一层本地服务而不是 MCP Server 直接跟桌面看板通信两个原因。一是 MCP Server 的生命周期跟着 Agent 走Agent 一关Server 就没了但我的看板要一直开着二是桌面看板可能同时接多个 Agent需要一个统一的状态中心做合并和去重。所以本地服务是常驻的MCP Server 只是个薄薄的转发层收到工具调用就通过本地 HTTP 或 Unix Socket 把事件转给常驻服务。WebSocket 那一段就没什么好纠结的了桌面端要实时收推送WebSocket 是最省事的选择浏览器环境和原生环境都支持得好。2.3 桌面端为什么用 Tauri 而不是 Electron这个选择我纠结了挺久。Electron 生态成熟写起来快但一个常驻桌面小看板用 Electron光运行时就好几十兆内存我开着 IDE 和浏览器已经够呛了再加一个 Electron 实在肉疼。Tauri 用系统 WebView 渲染打包出来体积小、内存占用低常驻场景下这个优势很实在。代价是跨平台 WebView 行为有差异尤其是我要做的透明窗口、无边框、置顶这些特性在三个平台上表现不完全一致得分别处理。但考虑到这是个自用为主的小工具我认了。像素桌宠那块本来想用 Live2D后来觉得太重直接用了逐帧 PNG 序列 Canvas 渲染。像素风的好处就是资源小、渲染简单一个 32x32 的精灵图集就能搞定待机、走路、完成三个状态。2.4 数据模型设计状态中心的数据结构我改了三版最后定成这样{ id: uuid-v4, title: 重构用户鉴权模块, status: pending | in_progress | done | blocked, source: agent-name-or-manual, created_at: 1730000000, updated_at: 1730000100, note: 可选备注, order: 3 }几个设计点值得说。status用四个状态而不是简单的 todo/done是因为实际用下来 Agent 经常会有卡住了的情况blocked状态能让看板用红色高亮提醒我该介入了。source字段是为了区分这条待办是 Agent 加的还是我手动加的多 Agent 场景下还能知道是哪只 Agent 在干活。order是手动排序用的Agent 加的任务默认追加到末尾。注意id一定要用 UUID 而不是自增整数。多 Agent 并发写入时自增 ID 需要中心化分配容易成为瓶颈和冲突点UUID 让每个写入方自己生成天然无冲突。3. MCP Server 的实现细节与踩坑3.1 MCP 工具怎么定义才顺手MCP 的工具定义看起来简单但工具粒度设计得好不好直接决定 Agent 用起来顺不顺。我一开始只暴露了一个update_todos让 Agent 传整个列表过来。结果 Agent 每次都要先list再改再全量写回token 浪费严重而且并发时后写的会覆盖先写的。后来拆成了细粒度工具最终暴露这几个工具名参数用途add_todotitle, note?新增一条待办update_todoid, status?, title?, note?更新指定待办complete_todoid标记完成语义化快捷方式list_todosstatus?查询当前待办clear_completed无清理已完成项complete_todo其实是update_todo的语法糖但单独列出来是有意的。因为 Agent 在生成工具调用时完成任务这个动作出现频率极高给它一个语义明确的专用工具能显著降低它传错参数的概率。实测下来加了complete_todo之后Agent 误把状态写成done之外值的概率基本降到零。3.2 工具描述怎么写 Agent 才不犯迷糊这是我觉得最值得分享的一点。MCP 工具的description字段不是写给人看的文档是写给模型看的提示词。我第一版描述写得很文档化比如更新待办事项的状态结果 Agent 经常不知道该传哪些参数。改写成带明确约束和示例的描述后效果好很多。比如update_todo的描述我最终写成这样更新一条已存在的待办事项。必须提供 id。 status 只能是以下四个值之一pending, in_progress, done, blocked。 当你开始处理某条待办时先调用本工具把 status 改为 in_progress。 当任务遇到无法继续的阻碍时改为 blocked 并在 note 中说明原因。关键是把什么时候该调用也写进去。模型不是靠猜的你告诉它开始处理时改成 in_progress它就会在合适的时机调用。这一点在多个 Agent 协作时尤其重要因为状态流转的语义统一了看板上看到的状态才可信。3.3 并发写入的冲突处理多 Agent 同时跑的时候冲突是必然会遇到的。我的处理策略分两层第一层是服务端串行化。所有写操作进一个队列单线程处理保证状态变更的原子性。这个用 Node 的事件循环天然就能做到只要不在处理过程中await外部 IO 就行。第二层是乐观更新 版本号。每条待办带一个version字段更新时带上期望的版本号服务端比对不一致就拒绝并返回最新状态。这样 Agent 拿到冲突响应后可以重新list再改避免静默覆盖。function applyUpdate(todo, patch, expectedVersion) { if (todo.version ! expectedVersion) { return { ok: false, reason: version_mismatch, current: todo }; } const next { ...todo, ...patch, version: todo.version 1, updated_at: Date.now() }; return { ok: true, todo: next }; }提示版本号冲突在实际使用中其实很少触发因为 Agent 的操作通常是串行的。但一旦触发如果没有这层保护就会出现我明明看到任务没完成刷新一下又变成完成了这种灵异现象排查起来非常痛苦。加上它成本很低建议一开始就做。3.4 MCP Server 的启动与注册MCP Server 我用 Node 写的通过 stdio 跟 Agent 通信。注册到 Agent 的配置大概长这样不同 Agent 配置文件位置不同但结构类似{ mcpServers: { todo-board: { command: node, args: [/path/to/todo-board-mcp/dist/index.js], env: { BOARD_ENDPOINT: http://127.0.0.1:7788 } } } }这里有个坑要提醒BOARD_ENDPOINT指向的常驻服务必须先启动否则 MCP Server 转发会失败。我的做法是 MCP Server 启动时先探测一次端点探测不到就在 stderr 打警告但不退出——因为 Agent 可能先启动用户后启动看板。等看板起来后MCP Server 会自动重连。这个容忍启动顺序的设计实际用起来省了很多为什么没反应的困惑。4. 桌面看板与像素桌宠的实现4.1 透明置顶窗口的三个平台差异Tauri 里做透明无边框置顶窗口配置本身不复杂{ windows: [{ label: board, transparent: true, decorations: false, alwaysOnTop: true, skipTaskbar: true, width: 320, height: 480 }] }但三个平台的实际表现差异不小。Windows 上透明窗口需要开启macos-private-api之外的额外处理某些显卡驱动下会有黑边macOS 上alwaysOnTop配合transparent表现最稳但要注意别设成visibleOnAllWorkspaces否则切桌面时它会跟着跑很烦Linux 上则取决于桌面环境Wayland 下透明支持参差不齐X11 下基本没问题。我的处理是给窗口加一个点击穿透开关。默认不穿透因为我要能点它勾选任务但拖到屏幕边缘当装饰时可以切到穿透模式鼠标事件直接透过去不挡下面的窗口。这个开关用 Tauri 的setIgnoreCursorEvents实现切换时给个视觉反馈不然用户会以为程序卡死了。4.2 像素桌宠的状态机桌宠看着是个花架子但状态机设计不好会显得很傻。我给它定了四个状态idle待机偶尔眨眼、working有任务在 in_progress来回踱步、done任务完成蹦跳一下、blocked有任务卡住头顶冒问号。状态切换的触发点直接绑在待办状态变化上。这里有个细节done状态是瞬时的播完动画就回idle不能一直蹦否则很吵。我用一个定时器控制动画播完自动回落。精灵图集我用 Aseprite 画的32x32 一帧每个状态 4 帧循环。渲染用 CanvasrequestAnimationFrame驱动帧率锁在 12fps——像素风不需要高帧率锁低一点反而更有味道还省电。const FRAME_INTERVAL 1000 / 12; let lastFrameTime 0; function tick(now) { if (now - lastFrameTime FRAME_INTERVAL) { currentFrame (currentFrame 1) % framesPerState[currentState]; drawSprite(currentState, currentFrame); lastFrameTime now; } requestAnimationFrame(tick); }注意桌宠的 Canvas 一定要设image-rendering: pixelated否则缩放后像素会被插值糊掉完全没有像素味。这个 CSS 属性在三个平台的 WebView 里都支持放心用。4.3 看板 UI 的信息层级看板空间有限320x480 的窗口要塞下待办列表、状态、操作按钮信息层级必须清楚。我的排布是顶部一行是标题栏显示当前连接的 Agent 数量和总任务数可拖动。中间是任务列表每条任务一行左侧是状态色块灰待办蓝进行中绿完成红阻塞中间是标题右侧是操作按钮悬停才显示避免视觉噪音。底部是像素桌宠和手动添加按钮。状态色块这个设计我很满意。纯文字状态在快速扫视时不够直观一个色块扫一眼就知道整体进度。而且色块颜色跟桌宠状态联动视觉上是一致的。列表的排序规则也调过。最初按创建时间排后来发现进行中的任务应该置顶否则任务一多正在跑的那条被挤到下面看不见。最终排序是blockedin_progresspendingdone同状态内按order排。4.4 手动操作与 Agent 操作的协调看板上我能手动勾选、删除、加备注这些操作也要同步回状态中心再广播给其他客户端。这里有个容易忽略的点手动操作和 Agent 操作要打不同的 source 标记否则 Agent 下次list的时候会把自己没加过的任务当成自己的可能做出奇怪的决策。我的做法是手动操作统一标记source: manual并且在 MCP 的list_todos返回里默认过滤掉 manual 来源的任务——除非 Agent 显式要求查全部。这样 Agent 的世界里只有它自己加的任务不会被我的手动笔记干扰。5. 实际使用中的问题与排查实录5.1 常见问题速查表用了一个多月攒了不少问题整理成表方便对照现象可能原因排查方法解决看板一直不更新常驻服务没启动访问http://127.0.0.1:7788/health启动服务MCP Server 会自动重连Agent 说工具调用失败端点地址配错看 MCP Server 的 stderr 日志检查BOARD_ENDPOINT环境变量任务重复出现Agent 重试导致重复 add看source和created_at服务端按 titlesource 做短时去重状态卡在 in_progressAgent 崩了没回写看任务updated_at是否很久没变手动改状态或加超时自动回退窗口透明失效平台/驱动差异换 X11 或更新显卡驱动退化为不透明背景桌宠不动Canvas 尺寸为 0检查容器 CSS给容器明确宽高5.2 那个让我排查了两小时的幽灵任务有次我发现看板上多了一条我从没见过的任务标题还是乱码。查了半天最后定位到是 Agent 在生成工具调用参数时把一段代码片段误当成了 title 传进来。因为我的add_todo当时没做长度校验超长字符串直接进了数据库渲染时又因为字符问题显示成乱码。修复很简单加参数校验function validateTitle(title) { if (typeof title ! string) throw new Error(title must be string); const trimmed title.trim(); if (trimmed.length 0) throw new Error(title cannot be empty); if (trimmed.length 200) throw new Error(title too long (max 200)); return trimmed; }但这个坑的教训是永远不要相信模型传过来的参数格式。它大部分时候是对的但偶尔会给你惊喜。所有 MCP 工具的入参都要做类型、长度、枚举值校验校验失败返回明确的错误信息模型看到错误通常会自己纠正重试。5.3 多 Agent 场景下的状态归属同时跑两个 Agent 的时候一开始所有任务混在一起根本分不清谁是谁。后来加了source字段并在 UI 上做了区分不同 Agent 的任务左侧色块加一条细边颜色按 Agent 分配。这样一眼就能看出哪个 Agent 在忙、哪个闲着。还有个更隐蔽的问题两个 Agent 可能加标题完全一样的任务。这在重构类任务里很常见比如两个 Agent 都被要求修复登录 bug。我的处理是不做标题去重因为同名任务可能是不同 Agent 的不同工作强行合并反而丢信息。但会在 UI 上把同名任务视觉上归组避免列表看起来重复。5.4 性能上的几个实测数据自用场景下性能不是瓶颈但我还是测了一下给想扩展的人一个参考常驻服务内存占用稳定在 30MB 左右跑一整天不涨。看板窗口内存约 60MBTauri WebView比 Electron 方案省一半以上。从 Agent 调用工具到看板更新端到端延迟实测 50-120ms主要花在 WebSocket 往返上。任务列表到 200 条时渲染开始有轻微卡顿加了虚拟滚动后解决。提示如果你打算把任务数做到几百上千虚拟滚动是必须的。我用的方案很简单只渲染视口内的行滚动时动态替换。像素桌宠的 Canvas 是独立的不受列表滚动影响。6. 如果你想自己改一版从哪下手代码结构我刻意做得扁平方便改。核心就三个目录mcp-serverMCP 工具实现、core-service状态中心和 WebSocket 广播、desktopTauri 看板 桌宠。想换桌宠形象只改desktop/src/sprites下的图集和状态配置就行想加新工具在mcp-server里加一个 handler 注册进去想换 UI 风格desktop的样式是独立的 CSS改起来不影响逻辑。我个人在实际使用中体会最深的一点是给 Agent 做外置状态展示价值不在于好看而在于把 Agent 的内部状态变成可观测的。以前它卡住了我完全不知道现在看板上一片红我立刻就知道该去看看它卡在哪。这个从黑盒等待到可观测的转变才是这个项目真正解决的问题。至于像素桌宠它确实没什么实际功能但每次任务完成它蹦一下的时候等待这件事好像也没那么难熬了。