SLOPSHOPPER

loop-breaker

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…

newbandguardtoast
★ 1v0.1.0MITupdated 2026-10-26StartupBros-com/hov-stack/mods/loop-breaker
A shopper browsing a rack in a slop shop
README

loop-breaker

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:

  • 2 identical failures: the tool result carries a hint Claude reads: change the arguments or the approach, the next identical attempt will be refused.
  • 3 identical failures: the next identical attempt is refused before it runs, with a reason Claude can act on (state what you tried, read the error, take a different approach or ask the user).
  • Above the prompt: a face that goes from (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.

Why a mod and not a hook

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.

Install

claude --plugin-dir ./loop-breaker

Or from a marketplace once it is published there:

/plugin install loop-breaker@hov

Options

Set in /config under the plugin, or in settings.json pluginConfigs:

OptionDefaultWhat it does
threshold3Identical 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.

What it can touch

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.

Limits

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.

Develop

claude plugin validate ./loop-breaker
claude plugin test ./loop-breaker

MIT. StartupBros / House of Vibe.

Source 2 files
hooks/register.tsx 254 lines
1import { 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}
254
types/index.d.ts 11 lines
1declare 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