1. 为什么要在 React 项目里给 Agent 装 Hooks 和 Canvas
Cursor 的 Agent 循环默认是个黑盒:它读文件、跑命令、改代码,你在聊天窗口里只能看到结果摘要。项目一大,问题就来了——Agent 改了哪些文件、中间产物长什么样、某一步为什么被跳过,全靠翻 transcript 猜。我试过在一个中型 React 后台项目里让 Agent 连续重构十几个组件,结果它中途把某个工具调用静默失败了,聊天里只显示「已完成」,实际有四个文件没动。
Hooks 解决的正是这个「确定性」问题。Rules 和 Skills 管的是 Agent 知道什么、会做什么,而 Hooks 管的是在 Agent 循环的每个关键节点上,一定会发生什么。它通过 JSON 配置挂载 shell 脚本,脚本用 stdin 收 JSON、stdout 回 JSON,和 Cursor 双向通信,不依赖模型判断。也就是说,格式化、审计、权限拦截这类操作,只要配了 Hook,就一定会执行。
Canvas 补的是交付形态。当 Agent 的分析结果是一张变更热力图、一份审计清单、一个指标仪表盘时,塞进聊天 Markdown 里既难读也难交互。Canvas 是一个.canvas.tsx文件,本质是独立渲染的 React 面板,出现在聊天旁边,还能 Publish 成团队可访问的链接。
这篇要做的,是把两者串起来:用 Hooks 拦截 Agent 执行节点,把中间产物实时写到一个 JSON 文件里,再用一个 Canvas 组件读取并渲染成可视化面板。适合已经在用 Cursor 做 React 开发、想让 Agent 流程可控可观测的同学。下面所有配置和代码都可以直接复制到项目里跑。
2. TaoToken 前置:给 Agent 循环准备一个稳定的模型入口
Hooks 和 Canvas 本身是 Cursor 的本地能力,但 Agent 循环背后要调模型。如果你在团队里做工程化落地,模型入口的稳定性直接决定 Hook 触发是否连续——模型请求一断,Agent 循环就停在中途,stop事件可能都不触发,你的可视化面板就永远等不到数据。
我现在的做法是把模型调用统一走 TaoToken。它的官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api (这个地址不加 UTM 参数)。对 Cursor 这类工具来说,你只需要在模型配置里填好 base URL 和 API Key,Agent 循环的每一次工具调用、每一轮推理都走这个入口。
具体操作路径是这样:先到控制台创建密钥,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面生成一个 key,页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。拿到 key 之后,如果你要验证模型是否通,可以直接在模型对话页面试一条,地址 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。
这里有个关键点:Hooks 脚本里如果要读环境变量,别把 key 硬编码进.cursor/hooks.json,那个文件是随仓库提交的。正确做法是写进本地.env或者系统环境变量,Hook 脚本里用$TAOTOKEN_API_KEY引用。后面第 3 节的配置骨架里我会标出这个位置。
如果你打算长期跑编码类 Agent 任务,比如让 Agent 连续重构、批量生成组件,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合这种高频、长链路的循环场景。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,配置细节以文档为准。
3. 可复制配置:Hooks 骨架 + Canvas 组件代码
这一节是全文的核心,分三块:Hook 配置 JSON、Hook 脚本、Canvas 组件。目标是把 Agent 每次文件编辑和工具调用记录到一个agent-events.json,Canvas 读它渲染。
3.1 Hook 配置 JSON
在项目根目录建.cursor/hooks.json,内容如下。这里挂了三个事件:afterFileEdit记录文件变更,postToolUse记录工具调用,stop在 Agent 循环结束时打一个收尾标记。
{ "version": 1, "hooks": { "afterFileEdit": [ { "command": "./hooks/record-event.sh file_edit", "timeout": 10 } ], "postToolUse": [ { "command": "./hooks/record-event.sh tool_use", "timeout": 10 } ], "stop": [ { "command": "./hooks/record-event.sh agent_stop", "timeout": 10 } ] } }字段说明:version当前固定为 1;hooks下每个事件名映射一个命令数组;command是脚本路径加参数;timeout防止 Hook 卡死阻塞 Agent。同一个事件可以挂多个 Hook,按配置顺序执行。
3.2 Hook 脚本:把中间产物写进 JSON
建hooks/record-event.sh,记得chmod +x。脚本从 stdin 读 Cursor 传来的 JSON,追加一条带时间戳的记录到.cursor/agent-events.json。
#!/bin/bash set -euo pipefail EVENT_TYPE="${1:-unknown}" PROJECT_DIR="${CURSOR_PROJECT_DIR:-$(pwd)}" EVENTS_FILE="$PROJECT_DIR/.cursor/agent-events.json" mkdir -p "$(dirname "$EVENTS_FILE")" [ -f "$EVENTS_FILE" ] || echo "[]" > "$EVENTS_FILE" INPUT=$(cat) python3 - "$EVENT_TYPE" "$EVENTS_FILE" <<'PY' import json, sys, time, os event_type = sys.argv[1] events_file = sys.argv[2] raw = sys.stdin.read() if not sys.stdin.isatty() else "" try: payload = json.loads(raw) if raw.strip() else {} except json.JSONDecodeError: payload = {"raw": raw[:500]} record = { "type": event_type, "ts": int(time.time() * 1000), "payload": payload, } with open(events_file, "r+", encoding="utf-8") as f: try: data = json.load(f) except json.JSONDecodeError: data = [] data.append(record) f.seek(0) f.truncate() json.dump(data, f, ensure_ascii=False, indent=2) print(json.dumps({"continue": True})) PY注意最后一行print(json.dumps({"continue": True})),这是回给 Cursor 的 stdout,表示 Hook 执行成功、Agent 可以继续。如果你要做拦截,比如beforeShellExecution里判断命令危险,就返回{"continue": false, "reason": "..."}。
脚本里用到了CURSOR_PROJECT_DIR这个环境变量,Cursor 会自动注入,指向当前工作区根目录。其他可用变量还有CURSOR_VERSION、CURSOR_USER_EMAIL、CURSOR_TRANSCRIPT_PATH。
3.3 Canvas 组件:读取事件并可视化
建agent-dashboard.canvas.tsx。Canvas 组件只从cursor/canvas包导入 UI 组件,数据必须内联或从本地文件读,不支持运行时网络请求。这里我们直接读上面生成的 JSON。
import { Canvas, Chart, Table, Card } from "cursor/canvas"; import eventsData from "./.cursor/agent-events.json"; type AgentEvent = { type: string; ts: number; payload: Record<string, unknown>; }; const events = eventsData as AgentEvent[]; const fileEdits = events.filter((e) => e.type === "file_edit"); const toolUses = events.filter((e) => e.type === "tool_use"); const stops = events.filter((e) => e.type === "agent_stop"); const timeline = events.map((e) => ({ time: new Date(e.ts).toLocaleTimeString(), type: e.type, detail: JSON.stringify(e.payload).slice(0, 80), })); export default function AgentDashboard() { return ( <Canvas title="Agent 循环可视化"> <Card title="事件统计"> <Table columns={["事件类型", "次数"]} rows={[ ["文件编辑", String(fileEdits.length)], ["工具调用", String(toolUses.length)], ["循环结束", String(stops.length)], ]} /> </Card> <Card title="事件时间线"> <Table columns={["时间", "类型", "详情"]} rows={timeline.map((t) => [t.time, t.type, t.detail])} /> </Card> <Card title="文件编辑分布"> <Chart type="bar" data={fileEdits.map((e) => ({ label: String(e.payload.file_path ?? "unknown"), value: 1, }))} /> </Card> </Canvas> ); }组件里Chart、Table、Card都来自cursor/canvas,具体可用组件以你本地版本为准。数据是构建时静态导入的,所以每次 Hook 写入新事件后,重新打开 Canvas 就能看到更新。
4. 验证请求:确认 Hook 触发与 Canvas 同步
配置写完,得验证两件事:Hook 到底有没有被触发,Canvas 有没有读到数据。
第一步,确认脚本可执行。在项目根目录跑:
chmod +x hooks/record-event.sh echo '{"file_path":"src/App.tsx"}' | ./hooks/record-event.sh file_edit cat .cursor/agent-events.json如果输出里出现一条type: "file_edit"的记录,说明脚本本身没问题。
第二步,触发真实 Agent 循环。在 Cursor 里让 Agent 做一个最小改动,比如「把 src/App.tsx 里的标题改成 Hello Agent」。Agent 执行文件编辑后,afterFileEdit应该被触发。再让它跑一条命令,比如「运行 npm run lint」,postToolUse应该被触发。
第三步,检查事件文件。再次cat .cursor/agent-events.json,你应该看到至少三条记录:file_edit、tool_use、agent_stop。如果只有前两条没有agent_stop,说明 Agent 循环没正常结束,可能是模型请求中断了——这时候回到第 2 节检查你的模型入口配置。
第四步,打开 Canvas。在 Cursor 命令面板运行Open Canvas,选择agent-dashboard.canvas.tsx。面板应该显示事件统计表、时间线表和文件编辑柱状图。如果表格是空的,说明 JSON 导入路径不对,检查.cursor/agent-events.json是否在组件同级目录下。
第五步,验证同步。让 Agent 再改一个文件,重新打开 Canvas,统计数字应该增加。这一步能确认 Hook 写入和 Canvas 读取是联动的。
5. 本篇常见错排查
Hook 不触发:最常见原因是.cursor/hooks.json位置不对。它必须在项目根目录的.cursor/下,不是用户目录。另外检查command路径是相对项目根目录的,./hooks/record-event.sh前面那个./不能省。
脚本报 permission denied:忘了chmod +x。在 macOS/Linux 上必须给执行权限,Windows 下建议用 Git Bash 或 WSL 跑。
stdin 读不到内容:有些事件类型传的 JSON 字段不一样,脚本里做了try/except兜底。如果payload一直是空的,打印一下原始输入调试:在脚本里临时加echo "$INPUT" >> /tmp/hook-debug.log。
Canvas 打不开或白屏:Canvas 只从cursor/canvas导入组件,别用第三方 UI 库。数据必须内联或静态导入,不支持fetch。如果导入 JSON 报错,确认tsconfig里开了resolveJsonModule。
事件文件越来越大:agent-events.json会随会话累积。生产项目里建议在sessionStartHook 里做一次归档,把旧文件移到.cursor/archive/下,避免 Canvas 加载过慢。
Hook 阻塞 Agent:timeout设太小会导致脚本被 kill,设太大又可能卡住循环。文件记录类 Hook 给 10 秒足够,涉及网络或重命令的给 30 秒。脚本里避免同步等待外部服务。
模型请求中断导致 stop 不触发:这是循环层面的问题,不是 Hook 本身。检查你的模型入口是否稳定,必要时换用更稳的接入方式,参考第 2 节的配置路径。
6. 把 Agent 循环变成可观测的工程流水线
Hooks 加 Canvas 这套组合,本质是把 Agent 从「聊天工具」变成「可观测的工程流水线」。你可以在afterFileEdit里接 ESLint 自动修复,在beforeShellExecution里拦截危险命令,在stop里触发一次完整测试,所有中间产物都落到 JSON,再由 Canvas 渲染成团队能看懂的面板。
下一步可以做的扩展:把agent-events.json按会话分文件,Canvas 里加一个会话选择器;或者用afterAgentResponseHook 把每轮响应摘要也记进去,时间线会更完整。如果你想让 Agent 长时间跑批量任务,模型入口的稳定性是前提,Coding Plan 那类长期方案更适合这种场景,配置参考 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入细节和参数以官方文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 为准,别照搬博客里的过期字段。
最后提醒一句:Hook 脚本里永远不要硬编码 API Key,.cursor/hooks.json是随仓库走的。密钥放本地环境变量,脚本里用变量引用,这是团队协作的底线。