SLOPSHOPPER

loop-guard

Tells the model, out of the user's sight, to stop when the same call fails twice with the same error

newguardtoast
★ 5v0.1.0MITupdated 2026-10-03arasovic/claude-code-mods/loop-guard
A shopper browsing a rack in a slop shop
README

loop-guard

Stops Claude from running the same failing call over and over.

loop-guard's toast after a repeated failure

What it does

When a tool call fails twice with the same arguments and the same error, loop-guard adds a note to the second error. Only the model reads it; it tells the model not to try a third time, to re-read the error, and to change approach or ask you. A toast tells you the note went out.

  • The call must match: same tool, same arguments, same agent. A tool's description is left out, since Claude words it anew on each try. A subagent's failures count apart from the main thread's.
  • The error must match too. Running npm test again after a fix usually fails differently, so it is not flagged.
  • The note and the toast go out once per call. After the call succeeds, a new pair of failures sends them again.
  • A success of the same call clears its record. /clear and an interrupted turn (Esc) clear them all, since a call you stop fails the same way each time.

Limits

  • An error that carries a time or a random id differs each run, so it is never flagged.
  • Refused calls are not counted, and they do not clear a failure.
  • Only the last error of a call is kept: failing with A, then B, then A again sends no note.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install loop-guard@claude-code-mods

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh loop-guard
Source 1 files
hooks/register.ts 35 lines
1import type { Register } from 'claude-code'
2
3export const NOTE = 'loop-guard: an automatic note the user does not see. This exact call just failed again with the same error. Do not run it a third time. Re-read the error, question the assumption behind the call, then try a different approach or ask the user.'
4
5// tool_use_id differs on every call, and the model words a Bash or Agent description anew each time.
6// agentId stays, so a subagent's failures are its own.
7export const callKey = (e: object) => JSON.stringify({ ...e, tool_use_id: undefined, description: undefined })
8
9// A success of the call clears both.
10const lastErrorByCall = new Map<string, string>()
11const noted = new Set<string>()
12
13const forget = () => (lastErrorByCall.clear(), noted.clear())
14
15export const register: Register = on => {
16  on('tool.call', async ($, e, next) => {
17    const r = await next(e)
18    if (r.deny !== undefined) return r
19    const key = callKey(e)
20    if (!r.isError) return (lastErrorByCall.delete(key), noted.delete(key), r)
21    // Same call, same error: nothing changed between the two tries. A rerun after a fix fails differently.
22    const error = r.text ?? JSON.stringify(r.result)
23    if (lastErrorByCall.get(key) !== error) return (lastErrorByCall.set(key, error), r)
24    if (noted.has(key)) return r
25    noted.add(key)
26    // The toast is titled with the mod's name.
27    $.ui.toast(`the same ${e.tool} call failed twice; Claude was told to change approach`)
28    return { ...r, context: [...(r.context ?? []), NOTE] }
29  })
30
31  // An interrupt fails the running call with the same text every time; the user stopped it, Claude did not loop.
32  on('turn.complete', async ($, e, next) => (e.isAborted && forget(), next(e)))
33  on('session.end', async ($, e, next) => (forget(), next(e)))
34}
35