SLOPSHOPPER

circuit-breaker

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…

newspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · circuit-breaker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /breaker ⎿ circuit-breaker: Breaker: hold after 3 identical failures. /breaker <N> or /breaker off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

circuit-breaker

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install circuit-breaker@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/circuit-breaker

Use it

CommandWhat it does
/breakerShow the threshold. The default is 3.
/breaker 5Hold a command after 5 identical failures in a row. Saved across sessions.
/breaker offNever 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.

What it touches

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.

Tested with

  • Claude Code 2.1.295, claude plugin validate --strict and claude plugin test pass.

Limitations

  • "Identical" means the exact same command text. A retry with a changed flag or path is a new command, which is usually what you want.
  • Under claude -p, or if you dismiss the question, the held command is refused. The breaker never lets a command through on its own.
  • It watches the Bash tool only. A loop of failing Edit calls or MCP tool calls isn't caught.
  • A hook that times out or throws refuses that one command rather than letting it run. If that happens, run the command once more; the streak is in memory and resets on reload.

License

MIT, see the repository root.

Source 1 files
hooks/register.ts 135 lines
1// 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