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_todo | title, note? | 新增一条待办 |
update_todo | id, status?, title?, note? | 更新指定待办 |
complete_todo | id | 标记完成(语义化快捷方式) |
list_todos | status? | 查询当前待办 |
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 帧循环。渲染用 Canvas,requestAnimationFrame驱动,帧率锁在 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 数量和总任务数,可拖动。
- 中间是任务列表,每条任务一行,左侧是状态色块(灰=待办,蓝=进行中,绿=完成,红=阻塞),中间是标题,右侧是操作按钮(悬停才显示,避免视觉噪音)。
- 底部是像素桌宠和手动添加按钮。
状态色块这个设计我很满意。纯文字状态在快速扫视时不够直观,一个色块扫一眼就知道整体进度。而且色块颜色跟桌宠状态联动,视觉上是一致的。
列表的排序规则也调过。最初按创建时间排,后来发现进行中的任务应该置顶,否则任务一多,正在跑的那条被挤到下面看不见。最终排序是:blocked>in_progress>pending>done,同状态内按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 | 服务端按 title+source 做短时去重 |
| 状态卡在 in_progress | Agent 崩了没回写 | 看任务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 左右,跑一整天不涨。
- 看板窗口内存约 60MB(Tauri + WebView),比 Electron 方案省一半以上。
- 从 Agent 调用工具到看板更新,端到端延迟实测 50-120ms,主要花在 WebSocket 往返上。
- 任务列表到 200 条时,渲染开始有轻微卡顿,加了虚拟滚动后解决。
提示:如果你打算把任务数做到几百上千,虚拟滚动是必须的。我用的方案很简单,只渲染视口内的行,滚动时动态替换。像素桌宠的 Canvas 是独立的,不受列表滚动影响。
6. 如果你想自己改一版,从哪下手
代码结构我刻意做得扁平,方便改。核心就三个目录:mcp-server(MCP 工具实现)、core-service(状态中心和 WebSocket 广播)、desktop(Tauri 看板 + 桌宠)。想换桌宠形象,只改desktop/src/sprites下的图集和状态配置就行;想加新工具,在mcp-server里加一个 handler 注册进去;想换 UI 风格,desktop的样式是独立的 CSS,改起来不影响逻辑。
我个人在实际使用中体会最深的一点是:给 Agent 做外置状态展示,价值不在于"好看",而在于把 Agent 的内部状态变成可观测的。以前它卡住了我完全不知道,现在看板上一片红,我立刻就知道该去看看它卡在哪。这个从"黑盒等待"到"可观测"的转变,才是这个项目真正解决的问题。至于像素桌宠,它确实没什么实际功能,但每次任务完成它蹦一下的时候,等待这件事好像也没那么难熬了。