SLOPSHOPPER

stuck-detector

Notices when Claude repeats the same failing command or edit, warns you, nudges Claude to change approach, and stops runaway retry loops before they burn your…

newbandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stuck-detector
› 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 › /stuck ⎿ stuck-detector: 🔁 Stuck Detector is ON · 0 nudges sent all time ⎿ stuck-detector: Thresholds: warn 3× · nudge 5× · block 8× the identical failing call ⎿ stuck-detector: Recent calls: ⎿ stuck-detector: ✓ Write audit.ts ⎿ stuck-detector: ✓ Write cache.ts ⎿ stuck-detector: ✗ bun test ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🔁 Stuck Detector

Stops Claude from running the same failing command forever.

Retry loops quietly burn tokens and time. Stuck Detector watches tool calls and reacts in three steps:

TriggerReaction
Same call fails 3×🔁 toast and a status-line warning
Same call fails 5×, or the same error shows up 4× from different commandsClaude is nudged: "stop, diagnose, try something different"
Same call fails 8×🛑 the call is blocked until you send a new prompt

It also flags a call repeated 6× even when it succeeds (spinning) and 6+ edits to one file in a short span (churn). A new prompt from you resets everything, and subagents are tracked separately.

Install

/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install stuck-detector@claude-mods

Commands

  • /stuck: status and recent calls
  • /stuck reset · off · on
Source 2 files
hooks/register.tsx 211 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Alert } from '../types'
5
6const alert = atom({ plugin: 'stuck-detector', key: 'alert' } as const, null as Alert | null)
7
8// Failures of the exact same call inside the window before each reaction.
9const WARN_AT = 3
10const NUDGE_AT = 5
11const BLOCK_AT = 8
12const SPIN_AT = 6 // the same call repeated this often (even succeeding) looks like spinning
13const CHURN_AT = 6 // edits to one file inside the window
14const SAME_ERROR_AT = 4 // different calls, same error
15const WINDOW = 14
16const BAND_MS = 30_000
17
18type Entry = { sig: string; label: string; file?: string; failed: boolean; errKey: string }
19
20const hash = (s: string) => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)
21const squash = (s: string) => s.trim().replace(/\s+/g, ' ')
22const short = (s: string, n = 60) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
23const field = (e: object, key: string): string => {
24  const v = (e as Record<string, unknown>)[key]
25  return typeof v === 'string' ? v : ''
26}
27
28const signature = (e: { tool: string }): { sig: string; label: string; file?: string } => {
29  const file = field(e, 'file_path') || field(e, 'path')
30  switch (e.tool) {
31    case 'Bash': {
32      const c = squash(field(e, 'command'))
33      return { sig: `Bash:${c}`, label: short(c) }
34    }
35    case 'Edit':
36    case 'MultiEdit':
37      return { sig: `Edit:${file}:${hash(field(e, 'old_string') + field(e, 'new_string'))}`, label: `Edit ${short(file.split(/[\\/]/).pop() ?? file, 40)}`, file }
38    case 'Write':
39      return { sig: `Write:${file}:${hash(field(e, 'content'))}`, label: `Write ${short(file.split(/[\\/]/).pop() ?? file, 40)}`, file }
40    case 'Read':
41      return { sig: `Read:${file}:${field(e, 'offset')}`, label: `Read ${short(file.split(/[\\/]/).pop() ?? file, 40)}`, file }
42    default: {
43      const { tool, tool_use_id, agentId, ...args } = e as Record<string, unknown>
44      void tool_use_id
45      void agentId
46      return { sig: `${e.tool}:${hash(JSON.stringify(args))}`, label: e.tool }
47    }
48  }
49}
50
51// The same error from two different commands should look the same: strip numbers, paths and quotes.
52const errorKey = (text: string) =>
53  squash(text)
54    .slice(0, 300)
55    .toLowerCase()
56    .replace(/[a-z]:[\\/][^\s'"]+|\/[^\s'"]+/g, '<path>')
57    .replace(/\d+/g, '#')
58    .slice(0, 120)
59
60async function setAlert($: EngineInterface, level: Alert['level'], text: string) {
61  const at = await $.clock.now()
62  await update($, alert, () => ({ level, text, at }))
63}
64
65export const register: Register = on => {
66  // History per agent: a subagent's loops are its own.
67  const history = new Map<string, Entry[]>()
68  const reacted = new Set<string>()
69  let isOff = false
70
71  on('session.start', async ($, e, next) => {
72    await $.command.register({ name: 'stuck', description: 'Stuck Detector: status | reset | off | on' })
73    isOff = ((await $.store.get('off')) ?? false) === true
74    return next(e)
75  })
76
77  // A new instruction from you is a fresh start.
78  on('prompt.submit', async ($, e, next) => {
79    history.clear()
80    reacted.clear()
81    await update($, alert, () => null)
82    $.ui.status(undefined)
83    return next(e)
84  })
85
86  on('tool.call', async ($, e, next) => {
87    if (isOff) return next(e)
88    const agent = (e as { agentId?: string }).agentId ?? 'main'
89    const list = history.get(agent) ?? []
90    const { sig, label, file } = signature(e)
91    const recent = list.slice(-WINDOW)
92
93    // Level 3: the exact same call has already failed too many times. Do not run it again.
94    const failures = recent.filter(x => x.sig === sig && x.failed).length
95    if (failures >= BLOCK_AT) {
96      const text = `Stopped: "${label}" has failed ${failures} times in a row`
97      await setAlert($, 3, text)
98      $.ui.toast(`🛑 Stuck Detector ${text.toLowerCase()}. Claude was told to change approach.`, { timeoutMs: 9000 })
99      return {
100        deny:
101          `Stuck Detector refused to run this again: the identical call (${label}) has already failed ${failures} times. Retrying will not help. ` +
102          'Stop. Re-read the error output, say in one sentence what you think is actually wrong, then try something materially different: a different command, a different file, a smaller reproduction, or ask the user for help.',
103      }
104    }
105
106    const ran = await next(e)
107    if (ran.deny) return ran
108
109    const failed = ran.isError === true
110    const entry: Entry = { sig, label, file, failed, errKey: failed ? errorKey(ran.text ?? '') : '' }
111    list.push(entry)
112    history.set(agent, list.slice(-40))
113    const win = list.slice(-WINDOW)
114
115    const sameFails = win.filter(x => x.sig === sig && x.failed).length
116    const sameCalls = win.filter(x => x.sig === sig).length
117    const sameError = entry.errKey ? win.filter(x => x.failed && x.errKey === entry.errKey).length : 0
118    const fileEdits = file && /^(Edit|MultiEdit|Write)$/.test(e.tool) ? win.filter(x => x.file === file && x.sig.match(/^(Edit|Write):/)).length : 0
119
120    let level: Alert['level'] | 0 = 0
121    let text = ''
122    let nudge = ''
123
124    if (sameFails >= NUDGE_AT) {
125      level = 2
126      text = `${label} failed ${sameFails}×`
127      nudge = `The same call (${label}) has now failed ${sameFails} times. Do not run it again unchanged. Look at the actual error, state what you think the root cause is, and try a different approach (or ask the user).`
128    } else if (sameFails >= WARN_AT) {
129      level = 1
130      text = `${label} failed ${sameFails}×`
131    } else if (sameError >= SAME_ERROR_AT && sameFails < WARN_AT) {
132      level = 2
133      text = `same error ${sameError}× across different commands`
134      nudge = `You have hit the same error ${sameError} times with different commands: "${short(entry.errKey, 100)}". Different variations of the same idea are not working. Step back, diagnose the underlying cause, and consider a different strategy.`
135    } else if (sameCalls >= SPIN_AT) {
136      level = 1
137      text = `${label} repeated ${sameCalls}× (spinning?)`
138    } else if (fileEdits >= CHURN_AT) {
139      level = 1
140      text = `${fileEdits} edits to ${label.replace(/^\w+ /, '')} in a short span (churn?)`
141      nudge = `You have edited the same file ${fileEdits} times in a short span. Pause, re-read the file as it is now, and confirm your plan before making more edits.`
142    }
143
144    if (level > 0) {
145      const key = `${agent}|${level}|${sig}|${Math.floor(Math.max(sameFails, sameCalls, sameError, fileEdits) / 2)}`
146      if (!reacted.has(key)) {
147        reacted.add(key)
148        await setAlert($, level, text)
149        $.ui.toast(`🔁 Stuck? ${text}`, { timeoutMs: level === 2 ? 8000 : 5000 })
150        $.ui.status(`🔁 stuck: ${short(text, 40)}`)
151        if (nudge) {
152          const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[Stuck Detector] ${nudge}` }] }, agentId: agent === 'main' ? undefined : agent }).catch(() => undefined)
153          void r
154          await $.store.set('nudges', Number((await $.store.get('nudges')) ?? 0) + 1)
155        }
156      }
157    } else if (!failed && sameFails === 0) {
158      // A call that works again clears the warning.
159      const a = await read($, alert)
160      if (a) {
161        await update($, alert, () => null)
162        $.ui.status(undefined)
163      }
164    }
165
166    return ran
167  })
168
169  on('command.run', { command: 'stuck' }, async ($, e) => {
170    const arg = e.args.trim().toLowerCase()
171    if (arg === 'off' || arg === 'on') {
172      isOff = arg === 'off'
173      await $.store.set('off', isOff)
174      return { text: isOff ? '🔁 Stuck Detector is OFF.' : '🔁 Stuck Detector is ON.' }
175    }
176    if (arg === 'reset') {
177      history.clear()
178      reacted.clear()
179      await update($, alert, () => null)
180      $.ui.status(undefined)
181      return { text: '🔁 History cleared.' }
182    }
183    const total = Number((await $.store.get('nudges')) ?? 0)
184    const main = (history.get('main') ?? []).slice(-6)
185    return {
186      text:
187        `🔁 Stuck Detector is ${isOff ? 'OFF' : 'ON'} · ${total} nudge${total === 1 ? '' : 's'} sent all time\n` +
188        `Thresholds: warn ${WARN_AT}× · nudge ${NUDGE_AT}× · block ${BLOCK_AT}× the identical failing call\n` +
189        (main.length ? `Recent calls:\n${main.map(x => `  ${x.failed ? '✗' : '✓'} ${x.label}`).join('\n')}` : 'No calls yet since your last prompt.') +
190        '\n\n/stuck reset · off · on',
191    }
192  })
193
194  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
195    if (e.props.hasSurvey) return next(e)
196    const a = await read($, alert)
197    if (!a) return next(e)
198    const now = await $.clock.now()
199    if (now - a.at > BAND_MS && a.level < 3) return next(e)
200    const { Box, Text } = $.ui.resolve(e)
201    const color = a.level === 3 ? 'red' : a.level === 2 ? 'yellow' : 'cyan'
202    const icon = a.level === 3 ? '🛑' : '🔁'
203    return (
204      <Box>
205        <Text color={color} bold>{`${icon} Stuck Detector: ${a.text}`}</Text>
206        <Text dimColor>{a.level === 3 ? '  · send a new prompt to continue' : a.level === 2 ? '  · Claude was nudged to change approach' : '  · watching'}</Text>
207      </Box>
208    )
209  })
210}
211
types/index.d.ts 8 lines
1export type Alert = { level: 1 | 2 | 3; text: string; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'stuck-detector': { alert: Alert | null }
6  }
7}
8