Stop Claude from retrying the same failing shell command over and over. After N identical failures the next attempt is held, and you choose: stop, or try once…

Stop Claude from retrying the same failing shell command over and over. After the same Bash command fails three times in a row, the next identical attempt is held and you choose: Stop, or Try once more. Stop refuses the call with a message that tells Claude to explain what it thinks is wrong and change approach instead of trying again.
Use it when you leave Claude on a long task and don't want to come back to twelve attempts at the same broken command.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install circuit-breaker@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/circuit-breaker
| Command | What it does |
|---|---|
/breaker | Show the threshold. The default is 3. |
/breaker 5 | Hold a command after 5 identical failures in a row. Saved across sessions. |
/breaker off | Never hold a command. |
While a streak builds, the spinner reads Thinking · same command failed 2×. At the threshold a question appears in Claude's own dialog with the command in it. Press 1 to stop or 2 to let it try once more. If you let it run and it fails again, you're asked again.
When you stop it, Claude reads:
circuit-breaker: this command has failed 3 times in a row with the same result, and the user chose to stop. Do not run it again. Explain what you think is wrong, propose a different approach, or ask the user. Last error: command not found: frob
A streak is counted per agent, so a subagent's retries don't trip the main conversation's breaker and the other way round. The streak clears when that agent's turn ends, when the command succeeds, or when a different command runs.
From claude plugin validate ./mods/circuit-breaker:
hooks: session.start, command.run{command=breaker}, tool.call{tool=Bash}, turn.complete, ui.render{component=Spinner}
calls: $.command.register, $.store.get, $.store.set, $.ui.ask, $.ui.invalidate, $.ui.log
No environment variables, processes, or network. The threshold is the only thing saved, in Claude Code's plugin store.
claude plugin validate --strict and claude plugin test pass.claude -p, or if you dismiss the question, the held command is refused. The breaker never lets a command through on its own.MIT, see the repository root.
hooks/register.ts 135 lines1// circuit-breaker: stop Claude from retrying the same failing shell command.
2//
3// /breaker show the threshold
4// /breaker 5 hold a command after 5 identical failures in a row
5// /breaker off turn the breaker off
6//
7// A streak is kept per agent (main conversation or subagent) and cleared when
8// that agent's turn ends. Once a command has failed `threshold` times in a row,
9// the next identical call is held and the user picks: Stop, or Try once more.
10// Under `claude -p` nobody can answer, so the call is refused.
11
12const DEFAULT_THRESHOLD = 3
13const MAX_ERROR_CHARS = 300
14
15type Streak = { command: string; failures: number; lastError: string }
16
17// Per agent id ('main' for the main conversation). Lost on reload, which is fine.
18const streaks = new Map<string, Streak>()
19
20function agentKey(e): string {
21 return typeof e.agentId === 'string' ? e.agentId : 'main'
22}
23
24function shorten(text: string, max: number): string {
25 const line = text.replace(/\s+/g, ' ').trim()
26 return line.length > max ? line.slice(0, max - 1) + '…' : line
27}
28
29function errorTail(result): string {
30 const text = typeof result.text === 'string' ? result.text : typeof result.result === 'string' ? result.result : ''
31 return shorten(text.slice(-MAX_ERROR_CHARS * 2), MAX_ERROR_CHARS)
32}
33
34// The guard. Top level so static analysis can see its $ calls.
35async function guard($, e, next) {
36 const key = agentKey(e)
37 const streak = streaks.get(key)
38
39 let threshold = DEFAULT_THRESHOLD
40 try {
41 const saved = await $.store.get('threshold')
42 if (typeof saved === 'number') threshold = saved
43 } catch {
44 // A store that can't be read leaves the default in place.
45 }
46
47 if (threshold > 0 && streak && streak.command === e.command && streak.failures >= threshold) {
48 let answer = 'Stop'
49 try {
50 // The wait is inside a mods API call, so it doesn't count against the hook budget.
51 answer = await $.ui.ask(
52 shorten(e.command, 80) + ' has failed ' + streak.failures + ' times in a row. Let Claude try it again?',
53 ['Stop', 'Try once more'],
54 )
55 } catch {
56 // Dismissed, "Chat about this", or a claude -p run with nobody to ask.
57 }
58 if (answer !== 'Try once more') {
59 return {
60 deny:
61 'circuit-breaker: this command has failed ' + streak.failures + ' times in a row with the same result, ' +
62 'and the user chose to stop. Do not run it again. Explain what you think is wrong, propose a different ' +
63 'approach, or ask the user. Last error: ' + (streak.lastError || '(none captured)'),
64 }
65 }
66 }
67
68 const result = await next(e)
69 if (result.deny) return result
70
71 if (result.isError) {
72 if (streak && streak.command === e.command) {
73 streak.failures += 1
74 streak.lastError = errorTail(result)
75 } else {
76 streaks.set(key, { command: e.command, failures: 1, lastError: errorTail(result) })
77 }
78 $.ui.invalidate('ui.render')
79 } else {
80 streaks.delete(key)
81 }
82 return result
83}
84
85export function register(on) {
86 on('session.start', async ($, e, next) => {
87 try {
88 await $.command.register({
89 name: 'breaker',
90 description: 'Hold a shell command after it fails N times in a row',
91 argumentHint: '[N | off]',
92 immediate: true,
93 })
94 } catch (error) {
95 $.ui.log('could not register /breaker: ' + error)
96 }
97 return next(e)
98 })
99
100 on('command.run', { command: 'breaker' }, async ($, e) => {
101 const args = e.args.trim()
102 if (args === 'off') {
103 await $.store.set('threshold', 0)
104 return { text: 'Breaker off. Commands are never held.' }
105 }
106 if (args) {
107 const n = Number(args)
108 if (!Number.isInteger(n) || n < 1) return { text: 'Usage: /breaker <N> (a whole number, 1 or more) or /breaker off' }
109 await $.store.set('threshold', n)
110 return { text: 'Breaker set. A command is held after ' + n + ' identical failures in a row.' }
111 }
112 const saved = await $.store.get('threshold')
113 const threshold = typeof saved === 'number' ? saved : DEFAULT_THRESHOLD
114 return { text: threshold > 0 ? 'Breaker: hold after ' + threshold + ' identical failures. /breaker <N> or /breaker off' : 'Breaker off. /breaker <N> to turn it on.' }
115 }).catch(async () => ({ text: 'circuit-breaker: the command failed, so nothing changed.' }))
116
117 on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
118 if (next.called) return next(e)
119 return { deny: 'circuit-breaker: could not evaluate this command (' + next.error.kind + '), so it was not run. Try again.' }
120 })
121
122 // Clear the streak when the agent's turn ends. A subagent's turn carries its agentId.
123 on('turn.complete', async ($, e, next) => {
124 streaks.delete(agentKey(e))
125 return next(e)
126 })
127
128 // Show a building streak beside the spinner for the main conversation.
129 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
130 const streak = streaks.get('main')
131 if (!streak || streak.failures < 2) return next(e)
132 return next({ ...e, props: { ...e.props, suffix: ' · same command failed ' + streak.failures + '×' } })
133 })
134}
135