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

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

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.
description is left out, since Claude words it anew on each try. A subagent's failures count apart from the main thread's.npm test again after a fix usually fails differently, so it is not flagged./clear and an interrupted turn (Esc) clear them all, since a call you stop fails the same way each time.claude plugin marketplace add arasovic/claude-code-mods
claude plugin install loop-guard@claude-code-mods
Restart Claude Code.
claude plugin validate .
claude plugin test .
../typecheck.sh loop-guardhooks/register.ts 35 lines1import 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