Stops the model repeating the same tool call over and over: after N identical calls in a row it refuses the next one and tells the model to change approach.

Cuts off a model that keeps repeating the same tool call.
If the same tool is called with identical arguments more than N times in a row, the next call is refused with a message telling the model to change approach, and you get a toast. Each subagent is judged on its own calls. If the hook errors it fails open.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install loop-breaker@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/loop-breaker
Illustrative output.
Fourth identical call
This exact Bash call has already run 3 times in a row with the same arguments. Repeating it will not change the result. Re-read the output, change the arguments, or try a different approach.
Toast
loop-breaker: refused a Bash call repeated 4 times
| Option | Default | What it does |
|---|---|---|
maxRepeats | 3 | Identical calls allowed in a row |
Set options when installing, or later in /plugin → the mod → configure.
hooks/register.ts 24 lines1import type { Register } from 'claude-code'
2import { shouldBlock, signature, streak } from './lib'
3
4const KEEP = 20
5
6export const register: Register = (on, options) => {
7 const maxRepeats = Math.max(2, Number(options?.maxRepeats ?? 3))
8 // One history per loop, so a subagent repeating itself is judged on its own calls.
9 const history = new Map<string, string[]>()
10
11 on('tool.call', ($, e, next) => {
12 const loop = e.agentId ?? 'main'
13 const past = history.get(loop) ?? []
14 const sig = signature(e.tool, e as unknown as Record<string, unknown>)
15 if (shouldBlock(past, sig, maxRepeats)) {
16 $.ui.toast(`loop-breaker: refused a ${e.tool} call repeated ${streak(past, sig) + 1} times`)
17 history.set(loop, [])
18 return { deny: `This exact ${e.tool} call has already run ${streak(past, sig)} times in a row with the same arguments. Repeating it will not change the result. Re-read the output, change the arguments, or try a different approach.` }
19 }
20 history.set(loop, [...past, sig].slice(-KEEP))
21 return next(e)
22 }).catch(($, e, next) => next(e))
23}
24hooks/lib.ts 23 lines1// Arguments that vary between otherwise identical calls (the model rewrites a Bash
2// description each time) and so must not count when comparing.
3const NOISE = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'description', 'timeout', 'run_in_background'])
4
5/** A stable signature for a tool call: the tool plus its arguments, minus ids and free-text labels. */
6export function signature(tool: string, args: Record<string, unknown>): string {
7 const rest: Record<string, unknown> = {}
8 for (const k of Object.keys(args)) if (!NOISE.has(k)) rest[k] = args[k]
9 const sorted = Object.keys(rest).sort().map(k => [k, rest[k]])
10 return tool + ':' + JSON.stringify(sorted)
11}
12
13/** How many identical signatures end the history (the streak the next call would extend). */
14export function streak(history: string[], sig: string): number {
15 let n = 0
16 for (let i = history.length - 1; i >= 0 && history[i] === sig; i--) n++
17 return n
18}
19
20export function shouldBlock(history: string[], sig: string, maxRepeats: number): boolean {
21 return streak(history, sig) >= maxRepeats
22}
23