Stops Claude repeating the same failing tool call. Counts identical calls that error or are refused, hints after the second, refuses the next after the…

Stops Claude repeating the same failing tool call.
A Claude Code mod. When the exact same tool call (same tool, same arguments) fails or is refused again and again, loop-breaker steps in:
(o_o) to (O_O) to (>_<) as the run gets worse, with r Stop and rethink (ends the turn and asks Claude to say what it tried and what it will do differently) and d Dismiss.A success clears the count, so a retry that works is never punished. Each subagent is counted on its own. Everything resets at the start of each turn.
A settings hook sees one call at a time and can only refuse. loop-breaker needs the result of the previous call, adds a hint to a result, refuses before the permission prompt, and draws above the prompt. Those are mod-only moves.
claude --plugin-dir ./loop-breaker
Or from a marketplace once it is published there:
/plugin install loop-breaker@hov
Set in /config under the plugin, or in settings.json pluginConfigs:
| Option | Default | What it does | ||||
|---|---|---|---|---|---|---|
threshold | 3 | Identical failures allowed before the next identical attempt is refused (2 to 20) | ||||
allow | "" | Comma-separated tool names loop-breaker never refuses, for example Bash,Read | ||||
retryLater | `deferred\ | try again later\ | retry after\ | rate limit\ | ...` | A case-insensitive regular expression matched against the first 400 characters of a failed call's text; a match is "try again later", not a loop, and is not counted. Covers admission guards that defer a job, rate limits and dropped connections. Only the head is read, so a 503 buried in a test log still counts as a failure. Empty counts every failure. |
claude plugin validate ./loop-breaker prints the authority:
hooks: turn.start, turn.complete, tool.call, ui.render{component=AbovePrompt}
calls: $.prompt.submit, $.state.get, $.state.set, $.turn.abort, $.ui.resolve, $.ui.toast
It reads no files, runs no programs, makes no network calls and calls no model. It sees every tool call's arguments and result, which is how it tells one call from another; it stores none of them past the turn.
A seatbelt, not a boundary. It matches calls on exact arguments, so a retry with a trivially different command (an added space inside a string, a new flag) counts as a new call. It cannot tell a legitimate poll loop from a stuck one unless the failure says so in its first 400 characters (deferred, a rate limit): for a command that polls on purpose, such as gh pr checks while checks are pending, allowlist the tool, add its wording to retryLater, or raise threshold. A refused call stays refused until the next turn starts or a different call succeeds; d Dismiss clears the band, not the count.
claude plugin validate ./loop-breaker
claude plugin test ./loop-breaker
MIT. StartupBros / House of Vibe.
hooks/register.tsx 254 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4/**
5 * loop-breaker: the same tool call failing again and again is a loop.
6 *
7 * Every tool call is keyed by its agent, its tool and its normalised
8 * arguments. A call that errors or is refused counts one failure for that
9 * key; a success clears it. At `threshold - 1` failures the result carries a
10 * hint Claude reads. At `threshold` failures the next identical attempt is
11 * refused before it runs, with a reason Claude can act on. The band above
12 * the prompt shows how bad it is and offers Stop and rethink.
13 */
14
15const worst = atom({ plugin: 'loop-breaker', key: 'worst' } as const, 0)
16const lastTool = atom(
17 { plugin: 'loop-breaker', key: 'lastTool' } as const,
18 null as string | null,
19)
20
21const MAX_KEY = 8_000
22const MAX_KEYS = 500
23const DEFAULT_THRESHOLD = 3
24
25/** JSON with sorted keys and trimmed strings, so whitespace is not "different arguments". */
26export function stable(value: unknown): string {
27 if (typeof value === 'string') return JSON.stringify(value.trim())
28 if (value === null || typeof value !== 'object') return String(JSON.stringify(value))
29 if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'
30 const record = value as Record<string, unknown>
31 return (
32 '{' +
33 Object.keys(record)
34 .sort()
35 .map(key => JSON.stringify(key) + ':' + stable(record[key]))
36 .join(',') +
37 '}'
38 )
39}
40
41/** FNV-1a over the whole string, as two 32-bit lanes, so a long call never collides by truncation. */
42export function fnv1a(text: string): string {
43 let a = 0x811c9dc5
44 let b = 0x01000193
45 for (let i = 0; i < text.length; i += 1) {
46 const c = text.charCodeAt(i)
47 a = Math.imul(a ^ c, 0x01000193) >>> 0
48 b = Math.imul(b ^ c, 0x811c9dc5) >>> 0
49 }
50 return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
51}
52
53/** The identity of a call: who made it, which tool, which arguments (hashed in full). */
54export function keyOf(e: {
55 tool: string
56 tool_use_id?: string
57 agentId?: string
58 [k: string]: unknown
59}): string {
60 const { tool, tool_use_id: _id, agentId, ...args } = e
61 const canonical = stable(args)
62 const body = canonical.length <= MAX_KEY ? canonical : `${canonical.length}:${fnv1a(canonical)}`
63 return `${agentId ?? 'main'}\u0000${tool}\u0000${body}`
64}
65
66function thresholdOf(value: unknown): number {
67 const n = Math.floor(Number(value))
68 return Number.isFinite(n) && n >= 2 && n <= 20 ? n : DEFAULT_THRESHOLD
69}
70
71function allowOf(value: unknown): Set<string> {
72 const list = Array.isArray(value) ? value : String(value ?? '').split(',')
73 return new Set(list.map(s => String(s).trim()).filter(Boolean))
74}
75
76/**
77 * Failures that mean "try again later", not "this approach is wrong": an
78 * admission guard deferring a heavy job, a rate limit, a dropped connection.
79 * Retrying those identically is the right move, so they never count.
80 */
81export const DEFAULT_RETRY_LATER =
82 'deferred|try again later|retry after|rate limit|too many requests|\\b429\\b|\\b503\\b|ECONNRESET|ETIMEDOUT|EAGAIN|temporarily unavailable|already running'
83
84/** Only the head of a failure is read for retry-later words: a guard or a server says so up front. */
85export const RETRY_LATER_HEAD = 400
86
87function retryLaterOf(value: unknown): RegExp | null {
88 const source = typeof value === 'string' ? value.trim() : ''
89 if (source === '') return null
90 try {
91 return new RegExp(source, 'i')
92 } catch {
93 return new RegExp(DEFAULT_RETRY_LATER, 'i')
94 }
95}
96
97/** The words the model reads for a failed call, whatever shape the result took. */
98function failureTextOf(ran: { deny?: string; text?: string; result?: unknown }): string {
99 if (ran.deny !== undefined) return ran.deny
100 if (typeof ran.text === 'string') return ran.text
101 if (typeof ran.result === 'string') return ran.result
102 try {
103 return JSON.stringify(ran.result) ?? ''
104 } catch {
105 return ''
106 }
107}
108
109export const register: Register = (on, options) => {
110 const threshold = thresholdOf(options.threshold)
111 const allow = allowOf(options.allow)
112 const retryLater = retryLaterOf(options.retryLater ?? DEFAULT_RETRY_LATER)
113
114 /** Failures per call key, this turn. Module-level: a reload starts clean, which is fine. */
115 const fails = new Map<string, number>()
116 let turnId: string | null = null
117
118 on('turn.start', async ($, e, next) => {
119 turnId = e.turnId
120 fails.clear()
121 await update($, worst, () => 0)
122 await update($, lastTool, () => null)
123 return next(e)
124 })
125
126 on('turn.complete', async ($, e, next) => {
127 if (!e.agentId) turnId = null
128 return next(e)
129 })
130
131 on('tool.call', async ($, e, next) => {
132 if (allow.has(e.tool)) return next(e)
133
134 const key = keyOf(e as Parameters<typeof keyOf>[0])
135 const seen = fails.get(key) ?? 0
136
137 if (seen >= threshold) {
138 await update($, worst, w => Math.max(w, threshold + 1))
139 await update($, lastTool, () => e.tool)
140 $.ui.toast(`loop broken: ${e.tool} failed ${seen}x with identical arguments`)
141 return {
142 deny:
143 `loop-breaker refused this ${e.tool} call: it has failed ${seen} times in a row ` +
144 `with identical arguments. Do not retry it unchanged. State what you tried and what ` +
145 `the error said, then take a different approach: different arguments, a different ` +
146 `tool, or ask the user.`,
147 }
148 }
149
150 const ran = await next(e)
151 const failed = ran.deny !== undefined || ran.isError === true
152
153 if (!failed) {
154 fails.delete(key)
155 return ran
156 }
157
158 // "Try again later" is not a loop: leave the count where it is.
159 if (retryLater && retryLater.test(failureTextOf(ran).slice(0, RETRY_LATER_HEAD))) return ran
160
161 const count = seen + 1
162 if (fails.size >= MAX_KEYS && !fails.has(key)) {
163 const oldest = fails.keys().next().value
164 if (oldest !== undefined) fails.delete(oldest)
165 }
166 fails.set(key, count)
167 await update($, worst, w => Math.max(w, count))
168
169 if (count === threshold - 1 && ran.deny === undefined) {
170 return {
171 ...ran,
172 context: [
173 ...(ran.context ?? []),
174 `loop-breaker: this exact ${e.tool} call has now failed ${count} times with identical ` +
175 `arguments. Do not retry it unchanged. Read the error and change the arguments or ` +
176 `the approach. The next identical attempt after ${threshold} failures will be refused.`,
177 ],
178 }
179 }
180
181 return ran
182 })
183
184 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
185 const below = await next(e)
186 const level = await read($, worst)
187
188 if (e.props.hasSurvey || level < threshold - 1) return below
189
190 const { Box, Text, Button } = $.ui.resolve(e)
191 const tool = await read($, lastTool)
192
193 const face =
194 level > threshold
195 ? { glyph: '(>_<)', color: 'red', text: `loop broken: ${tool ?? 'a tool'} refused after ${threshold} identical failures` }
196 : level === threshold
197 ? { glyph: '(O_O)', color: 'red', text: `${level} identical failures in a row; the next identical try is refused` }
198 : { glyph: '(o_o)', color: 'yellow', text: `${level} identical failures in a row` }
199
200 const stop = async () => {
201 const id = turnId
202 await update($, worst, () => 0)
203 if (id) {
204 try {
205 await $.turn.abort({ turnId: id })
206 } catch {
207 // the turn had already ended; nothing to stop
208 }
209 }
210 try {
211 await $.prompt.submit({
212 text:
213 'Stop. The same call kept failing. In two or three sentences: what did you try, ' +
214 'what did the error say, and what different approach will you take? Then proceed ' +
215 'with that approach.',
216 })
217 } catch {
218 // nothing to submit into (a headless run); the abort alone was the point
219 }
220 }
221
222 const buttons = []
223 if (e.props.isWorking && turnId) {
224 buttons.push(
225 <Button key="stop" label="Stop and rethink" hotkey="r" onPress={() => void stop()} />,
226 )
227 }
228 buttons.push(
229 <Button
230 key="dismiss"
231 label="Dismiss"
232 hotkey="d"
233 plain
234 onPress={() => void update($, worst, () => 0)}
235 />,
236 )
237
238 const rows = [
239 <Box key="face" flexDirection="row" columnGap={2}>
240 <Text key="glyph" color={face.color} bold>
241 {face.glyph}
242 </Text>
243 <Text key="text" dimColor>
244 {face.text}
245 </Text>
246 {buttons}
247 </Box>,
248 ]
249 if (below) rows.push(below)
250
251 return <Box flexDirection="column">{rows}</Box>
252 })
253}
254types/index.d.ts 11 lines1declare module 'claude-code' {
2 interface PluginState {
3 'loop-breaker': {
4 /** The worst run of identical failures seen this turn: 0 when calm. */
5 worst: number
6 /** The tool whose identical call was last refused, for the band. */
7 lastTool: string | null
8 }
9 }
10}
11