
我用的每个编码代理都有同样的习惯。它完成工作写一个可爱的总结然后停止。摘要生活在聊天中。聊天滚动离开。这NOTES.md我让它保持更新从周二开始就没动过。你可以客气地问CLAUDE.md而且它多半会听。“大部分”在那句话里做了很多工作。Claude Code有一个更强大的工具:Stop hook这是一个在代理即将完成时运行的脚本允许它说“还没有”。我这周在Deiko发布了一个然后在同一天发布了一个补丁。修复教会了我一条规则我现在给任何写停止挂钩的人所以这就是这篇文章的方向。基础优先。停止挂钩是什么当它启动时当主代理完成响应时停止挂钩运行。用Esc中断时不运行。API错误会引发一个单独的StopFailure事件子代理拥有自己的事件SubagentStop.钩子生活在settings.json: ~/.claude/settings.json对于你所有的项目.claude/settings.json一次回购。有三个级别:事件、组和组内的处理程序。Stop没有匹配器(如果添加了匹配器它会被忽略)所以这个组只是一个包装器:{“hooks”: {“Stop”: [{ “hooks”: [{ “type”: “command”, “command”: “node”,“args”: [KaTeX parse error: Expected EOF, got } at position 54: …es-guard.mjs] }̲] } ] } }…{CLAUDE_PROJECT_DIR}.该脚本在stdin上获取一个JSON对象。对于Stop您将使用的部件如下所示:{“session_id”: “abc123”,“transcript_path”: “~/.claude/projects/…/00893aaf-…jsonl”,“cwd”: “/Users/you/project”,“hook_event_name”: “Stop”,“stop_hook_active”: false,“last_assistant_message”: “I’ve finished the refactor. Here’s what changed…”}还有几个字段(permission_mode, background_tasks, session_crons)但这些才是这里重要的。“阻止”如何让代理继续工作不打印任何内容并退出0代理正常停止。要让它继续工作请退出0并在stdout上打印以下内容:{ “decision”: “block”, “reason”: “Run the tests before you finish.” }reason是格挡时所必需的这也是克劳德读到的下一条指令。您也可以通过退出2来阻塞在这种情况下您的stderr将成为原因。每个挂钩选择一种风格。这里的陷阱是1号出口。每个Unix本能都说1表示“否”但是对于Stop钩子来说这是一个非阻塞错误:您得到一个“钩子错误”通知代理仍然停止。如果您的脚本路径键入错误也是如此。一个警卫打错了字settings.json不防守任何东西所以看第一回合。另外:stdout必须是唯一的JSON。一个打印问候语的shell配置文件可以落在它前面然后你的块就被悄悄地忽略了。你可以复制一个Stop hook的例子这是我实际使用的一个:不要在没有接触的情况下完成一个转弯NOTES.md。有趣的部分是“一个转弯”。钩子需要知道这个回合是什么时候开始的Claude Code提供了一个文档化的方法来找出答案UserPromptSubmit钩子在你发送提示的时候触发。所以一个脚本处理两个事件。在提示符下它会记下时间。在停止时它检查NOTES.md从那以后就变了。#!/usr/bin/env node// .claude/hooks/notes-guard.mjs: runs on UserPromptSubmit and Stop.import { readFileSync, writeFileSync, statSync } from “node:fs”;import { join } from “node:path”;import { tmpdir } from “node:os”;try {const input JSON.parse(readFileSync(0, “utf8”));const mark join(tmpdir(),notes-guard-${input.session_id});if (input.hook_event_name “UserPromptSubmit”) {writeFileSync(mark, String(Date.now())); // when this turn began} else if (input.hook_event_name “Stop” !input.stop_hook_active) {const began Number(readFileSync(mark, “utf8”));const notes join(process.env.CLAUDE_PROJECT_DIR ?? input.cwd, “NOTES.md”);let edited 0;try { edited statSync(notes).mtimeMs; } catch {}if (edited began) {console.log(JSON.stringify({decision: “block”,reason: “Before you stop: add a line to NOTES.md saying what you changed this turn and why.”,}));}}} catch {} // anything odd: let the agent stop在两个事件下注册相同的命令:{“hooks”: {“UserPromptSubmit”: [{ “hooks”: [{ “type”: “command”, “command”: “node”,“args”: [“KaTeX parse error: Expected EOF, got } at position 54: …es-guard.mjs] }̲] }], Stop…{CLAUDE_PROJECT_DIR}/.claude/hooks/notes-guard.mjs”] }] }]}}它很严格:每一轮都必须以注释结束包括“这个函数是做什么的”转弯。这对于长时间的自主会话来说没什么问题但对于喋喋不休的会话来说就有些烦人了。将条件换成对您来说“完成”意味着什么:一个测试运行的退出代码、一个changelog行、一个docs/。保持形状。如果你根本不需要脚本克劳德代码也支持提示和代理挂钩停止时模型决定工作是否完成而/goal命令对于一个会话也是如此。对于像“所有任务都完成了吗”那是更好的选择。对于任何可以用文件时间戳或退出代码检查的东西脚本更便宜而且每次都给出相同的答案。无限循环陷阱写一个Stop钩子只要条件不满足就阻塞迟早条件不能满足。测试需要一个没有运行的数据库。代理对没有写权限NOTES.md。你的钩子阻塞代理尝试失败停止你的钩子再次阻塞。那是什么stop_hook_active是为了。它是true当克劳德已经在继续比赛时因为一个止钩挡住了。先检查一下让代理走:你问了一次它试过了继续前进。克劳德代码也有一个逆止器:在连续八个街区后它超越钩子并结束转弯(CLAUDE_CODE_STOP_HOOK_BLOCK_CAP改变数字)。虽然八轮代理被唠叨仍然是八轮代币。检查旗帜。上面的示例在if其他一切都存在于try那就是“让它停下来”。一个丢失的时间戳文件、一个损坏的标准输入、一个不可读的路径:所有这些都正常地结束了这个回合。一个失败的守卫把一个坏文件变成了一个不能完成任何事情的代理。一个真实的:Deiko的回报钩如果您是新来的请快速查看上下文(或者从让克劳德代码在会话之间有记忆): Deiko是一个Mac应用程序你可以指着屏幕上的东西说话它会把你的意思转化成代码代理的简介。每一份简报都被归档到一个任务中每个任务都有一个注释:它的位置决定了什么特工报告了什么。最后一部分只有在代理通过save_outcome工具或通过编写一个outcome.md把简短的名字归档。这关于建立记忆的帖子有长版本。特工会忘记。所以在0.5.8中在设置中连接内存也给克劳德代码增加了一个Stop钩子。整件事是大约70行没有依赖关系的节点它做出四个决定:只根据Deiko的指示行动。每个摘要都包含一个要求报告的固定句子该句子指定了摘要的id及其路径outcome.md。钩子用正则表达式寻找那个句子。没判刑没意见。检查文件而不是对话。 save_outcome写的一样outcome.md摘要指向所以一个检查覆盖了两条路线:在摘要到达之后文件被修改了吗到达时间来自笔录中摘要的时间戳另一面是文件的时间。屏蔽一次。如果没有报告它会用一个命名摘要的原因和两种保存方法来阻止。如果stop_hook_active是真的它什么也不做。永远不要妨碍代理人。错误的JSON丢失的抄本不可读的文件:钩子不打印任何东西并退出0。这段注释逐字记录在代码中。决策是一个函数它返回null或者block对象传递文件访问这样测试就可以伪造它:export function decide(input, { read § readFileSync(p, “utf8”), mtime § statSync§.mtimeMs } {}) {if (!input || input.stop_hook_active || !input.transcript_path) return null;let transcript;try { transcript read(input.transcript_path); } catch { return null; }const brief latestBrief(transcript);if (!brief) return null;let saved 0;try { saved mtime(brief.outcome); } catch { /* not written yet */ }if (saved brief.at) return null;return { decision: “block”, reason:Before you finish: save what you did for Deiko brief ${brief.id} ...};}安装它是无聊的部分我希望它无聊。苹果应用程序显示~/.claude/settings.json如果它不能解析就拒绝碰它。它通过脚本的文件名找到自己的条目所以移动的应用程序会替换它的旧条目而不是添加第二个条目而你的其他钩子会留在原来的位置。它保留一个备份写入一个临时文件并进行重命名然后读回该文件进行确认。断开连接只会删除Deiko的条目。代码在这里如果你正在编写自己的安装程序。bug:从未布置的家庭作业第一版寻找最新的简报任何地方在笔录里。这听起来没错直到你有一个漫长的工作会议。我在早期的Claude代码中粘贴了一个摘要不是为了工作而是为了讨论它。我们聊了会儿然后转移到其他事情上。很多其他的事情。在那之后的每一个回合结束的时候钩子都要一份简报。每个回合它只被阻挡了一次所以没有任何循环。但是stop_hook_active每出现一个新的提示就重置一次而摘要仍然在抄本中后面没有任何结果。所以:挡每一个回合永远。最好的情况是一个代理人浪费了一个回合解释说它没有工作。最糟糕的情况是一个代理人热心地为它从未做过的工作发明一个报告并把它归档到那个任务的内存中。该修复程序已发布0.5.109月30日。这里是心脏的差速器:// 0.5.8: remember the last brief seen anywhereconst m MARKER.exec(text(entry.message?.content));if (m) found { id: m[1], outcome: m[2], at: Date.parse(entry.timestamp) || 0 };// 0.5.10: every real prompt resets it, so only the latest one countsif (entry.type ! “user” || entry.isMeta) continue;const body text(entry.message?.content);if (!body || body.startsWith(“Stop hook feedback”)) continue;const m MARKER.exec(body);latest m ? { id: m[1], outcome: m[2], at: Date.parse(entry.timestamp) || 0 } : null;新规则:钩子只关心最新的东西你们打字。如果这是一份简报请查看报告。如果是其他事情简报不是本回合的工作钩子不参与其中。跳过和规则一样重要。在Claude Code的脚本中许多条目被标记为来自你从未输入的用户:工具结果、像系统提醒这样的元条目以及钩子自己的反馈。一旦“最新的胜利”每一个都将消灭简报。工具结果在中途到达会让钩子认为你已经继续前进了。因此工具结果会被删除(只计算文本内容)元条目会被删除任何以“Stop hook feedback”开头的条目都会被删除。将范围扩大到当前回合以下是通用版本适用于任何停止挂钩:停钩应该判断这个回合而不是整个回合。“测试运行了吗”意思是从最后一次提示开始而不是永远。“NOTES.md更新了吗”意味着从这个回合开始而不是从周二开始。无论你检查什么条件锚定到当前提示到达的时刻并确保锚定是一个人实际发送的提示。有两种方法可以找到那一刻。有记录的是一个UserPromptSubmit钩子来记录它就像上面的例子。它还会收到提示文本因此您可以立即决定这一回合是否是您关心的回合。Deiko的钩子取而代之的是读取脚本这使得脚本在settings.json。成本取决于文字记录的格式这不是一个公开的API文件警告文件可能会落后于对话。开始转弯的提示是很早就写好的所以在停止射击的时候它就在那里了。我仍然会防御性地解析它这就是为什么每一个不好的行都被跳过而不是致命的。如何测试止动钩将决策放在一个纯函数中将标准输入放在底部的几行代码中。然后您可以用一个假的脚本来测试这个函数在测试中构建成几行JSON代码:test(“a brief pasted earlier and then talked about does not nag later turns”, () {const t (…entries) entries.map((e) JSON.stringify(e)).join(“\n”);assert.equal(latestBrief(t(brief1, later)), null);assert.equal(latestBrief(t(brief1, toolResult, meta, feedback)).id, “20260929-100000”);});Deiko的六项测试使用node:test别无其他。它们包括阻塞一次而不是两次、新报告与旧报告的对比、没有摘要的聊天、stdin上的垃圾以及上面的bug。在修复之前编写bug的测试。看着它变红有种奇怪的满足感。然后使用真正的CLI进行一次端到端的检查因为在错误键入的路径后面的完美函数是一个禁用的钩子。在暂存文件夹中将示例中的设置保存为hook-settings.json并运行:claude -p “Add a comment to the top of app.js”–settings hook-settings.json–permission-mode acceptEdits–debug-file hook.log-p在没有交互式UI的情况下运行一个提示符–settings为这次比赛装上你的钩子acceptEdits让代理在不询问的情况下写文件调试日志记录哪些钩子匹配它们的退出代码和它们的输出。如果通过NOTES.md之后仍然存在hook.log显示您的停止钩运行。所有四个标志都在CLI参考.如果你写了一个以有趣的方式出错的Stop hook我很想听听。我是Deiko_App在x上。停止挂钩不起作用通常的原因大多数断裂的止动钩以五种方式中的一种失效。开始Claude代码时使用https://www.iissbbs.com /claude --debug日志在~/.claude/debug/.txt显示每个钩子打印的内容以及如何被读取。它从不开火。类型/hooks并检查它是否列在Stop下。如果不是则设置文件位于错误的位置(.claude/settings.json对于一个项目来说~/.claude/settings.jsonfor everything)或者不是有效的JSON:没有尾随逗号没有注释。Stop不带匹配器所以匹配器没用。当您中断Claude时它也不会触发而是触发一个API错误StopFailure相反。带有JSON验证消息的“停止挂钩错误”。您的输出被解析为JSON但是不符合模式:一个错误级别的字段或者decision除了block。要让代理停止不要打印任何内容。带有解析消息的“停止挂钩错误”。输出开始于{但不是有效的JSON通常是原因中的一个引用。用…建造它JSON.stringify或者jq千万不要用胶水把线粘在一起。JSON被忽略没有错误。印在它前面的东西通常是echo在您的shell配置文件中因此stdout不再以{Claude Code将其作为纯文本读取。将该回显包装在交互式shell检查中或者在exec形式下运行钩子args: []所以不涉及壳。它一直阻塞然后放弃。Claude Code在连续八个街区后取消停止钩并以警告结束转弯。阅读stop_hook_active如果是真的就放了那个特工。如果你的钩子在每个回合都卡住了检查一下它是否在判断当前的回合。所有这些都是官方的钩子故障排除指南值得一个书签。TL博士医生打印{“decision”:“block”,“reason”:“…”}让探员继续工作。支票stop_hook_active所以你问一次。让每一次失败都结束这个转折。并且只判断当前回合:将你的支票锚定在一个人实际发送的最新提示上。人们问的问题如何阻止Claude代码停止钩子永远循环下去从stdin上的JSON中读取stop_hook_active当它为真时让代理停止。这是真的当克劳德已经因为一个止钩被挡住而继续比赛的时候。Claude代码还会在连续八个代码块之后覆盖Stop挂钩但是您不希望依赖于此。Stop钩子应该退出2还是打印JSON要么冻结该站点。Exit 2使用您的stderr作为原因。使用{“decision”: block “” reason “:” … 退出0}提供了结构化的控制。每个钩子选一个。退出1不阻塞:Claude代码将其视为非阻塞错误代理停止。当我按Esc键时停止挂钩会运行吗不会。停止在主代理完成响应时运行而不是在您中断它时运行。API错误触发单独的StopFailure事件子代理触发SubagentStop。我需要一个脚本还是一个提示就能决定Claude代码还支持Stop上的提示和代理挂钩其中模型决定工作是否完成以及用于一个会话的/goal命令。当检查具有确定性时使用命令脚本如文件的修改时间或测试退出代码。为什么我的克劳德代码停止钩不起作用奔跑/hooks要检查它是否在Stop下注册请确保设置文件是正确位置的有效JSON并使用claude --debug看看钩子上印着什么。最常见的静默原因是JSON之前的额外输出比如shell-profile回显。“Stop hook错误:JSON验证失败”是什么意思您的挂钩打印了一个与停止挂钩模式不匹配的JSON对象例如一个放错位置的字段或一个除“block”之外的决策。代理无论如何都会停止。要阻止请打印{“decision”:block “” reason “:” … };要让它停止什么也不要打印。