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…

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:
| Trigger | Reaction |
|---|---|
| Same call fails 3× | 🔁 toast and a status-line warning |
| Same call fails 5×, or the same error shows up 4× from different commands | Claude 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.
/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install stuck-detector@claude-mods
/stuck: status and recent calls/stuck reset · off · onhooks/register.tsx 211 lines1import { 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}
211types/index.d.ts 8 lines1export 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