SLOPSHOPPER

loop-breaker

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.

newguardtoast
A shopper browsing a rack in a slop shop
README

loop-breaker

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.

Install

/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

Use it

  • Automatic once installed.

What you'll see

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

Options

OptionDefaultWhat it does
maxRepeats3Identical calls allowed in a row

Set options when installing, or later in /plugin → the mod → configure.

Source 2 files
hooks/register.ts 24 lines
1import 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}
24
hooks/lib.ts 23 lines
1// 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