Stops the agent repeating the same failing command or undoing its own edits, and shows a stuck meter above the prompt


The second identical failure raises the stuck band; the third identical run is refused. MP4 · screenshots: warning, refused
A Claude Code mod that stops the agent from going round in circles.
Interrupted calls and commands moved to the background are not counted.
unstick button. It only shows the program name and a word or two, never tokens. It is drawn above any other mod's band, without hiding it./unstick clears all history and runs immediately, even mid-turn. A new prompt from you, /clear or a resume also clears it. Background notifications and scheduled prompts do not.Known limits:
History is kept per agent, and a subagent's history is dropped when it finishes. It lives in memory only (short hashes, never file contents) and starts over on a hot reload.
/plugin marketplace add ccdwyer/claude-mods
/plugin install loop-breaker@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=unstick}prompt.submitsession.endturn.completetool.callui.render{component=AbovePrompt}Engine calls it makes: $.command.register, $.fs.read (via undoesAgain), $.state.get, $.state.set, $.ui.resolve.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.
It runs entirely on your machine. It sends nothing over the network.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 413 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Stuck } from '../types'
5
6const stuckRef = { plugin: 'loop-breaker', key: 'stuck' } as const
7const blockedRef = { plugin: 'loop-breaker', key: 'blocked' } as const
8const stuck = atom(stuckRef, null)
9
10// Identical failures (same call, same error) allowed before the next try is refused.
11const MAX_FAILS = 2
12// Changes in a row that each swap a file back to its version before the last; the next is refused.
13const MAX_CHAIN = 3
14// Prompts that are a person's own words, so new direction.
15const PERSON = new Set(['composer', 'bridge', 'sdk', 'channel', 'slack-ping'])
16// Arguments that describe a call without changing what it does.
17const COSMETIC = new Set(['tool', 'tool_use_id', 'agentId', 'consent', 'description'])
18
19// A run of identical failures of one call: same arguments, same (normalized) error.
20type Streak = { label: string; count: number; error: string }
21// The latest run of whole-file swaps on one file: `from` and `to` hash its full text.
22type Chain = { from: string; to: string; length: number }
23type Loop = { streaks: Map<string, Streak>; chains: Map<string, Chain> }
24type Call = { tool: string; [k: string]: unknown }
25
26// The history is the module's own: every hook of this plugin runs in one
27// environment, one at a time between awaits, so parallel tool calls update it
28// without racing on a per-dispatch state snapshot. A hot reload starts it over.
29const loops = new Map<string, Loop>()
30// Bumped by a reset: results of calls started before it are dropped.
31let gen = 0
32// Bumped by every real change on disk: failures of calls started before it ran old code.
33let codeGen = 0
34let shown: Stuck | null = null
35let blockedCount = 0
36
37const loopOf = (agent: string): Loop => {
38 let loop = loops.get(agent)
39 if (loop === undefined) {
40 loop = { streaks: new Map(), chains: new Map() }
41 loops.set(agent, loop)
42 }
43 return loop
44}
45
46async function hash(text: string): Promise<string> {
47 const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
48 return [...new Uint8Array(bytes).slice(0, 8)].map(b => b.toString(16).padStart(2, '0')).join('')
49}
50
51// Deterministic JSON: object keys sorted at every depth.
52function stable(value: unknown): string {
53 if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
54 if (value !== null && typeof value === 'object') {
55 const entries = Object.keys(value)
56 .sort()
57 .map(k => `${JSON.stringify(k)}:${stable((value as Record<string, unknown>)[k])}`)
58 return `{${entries.join(',')}}`
59 }
60 return JSON.stringify(value) ?? 'undefined'
61}
62
63// Every argument that changes behaviour. Bash commands are kept exactly:
64// whitespace inside quotes and heredocs matters.
65function identity(e: Call): string {
66 const args: Record<string, unknown> = {}
67 for (const k of Object.keys(e)) if (!COSMETIC.has(k)) args[k] = e[k]
68 return `${e.tool}:${stable(args)}`
69}
70
71// The parts of an error that differ between runs of the same failure.
72export function normalizeError(text: string): string {
73 return text
74 .split('\n')
75 .filter(line => !/saved to:|persisted output|output too large/i.test(line))
76 .join('\n')
77 .replace(/(\/private)?\/var\/folders\/\S+/g, '<tmp>')
78 .replace(/(\/private)?\/tmp\/\S+/g, '<tmp>')
79 .replace(/\d{4}-\d\d-\d\d[T ][\d:.]+Z?/g, '<ts>')
80 .replace(/\b\d{1,2}:\d\d:\d\d(\.\d+)?\b/g, '<ts>')
81 .replace(/\b\d+(\.\d+)?\s?(ms|s|sec|secs|seconds|min|minutes)\b/gi, '<t>')
82 .replace(/0x[0-9a-f]{6,}/gi, '<addr>')
83 .replace(/\bpid[ =:]?\d+/gi, 'pid <n>')
84 .trim()
85}
86
87// Public subcommands per program; anything else (make targets, script names) is a value.
88const VERBS: Record<string, string[]> = {
89 git: ['add', 'commit', 'push', 'pull', 'fetch', 'rebase', 'merge', 'checkout', 'switch', 'status', 'diff', 'log', 'stash', 'reset', 'restore', 'clone', 'tag', 'branch', 'cherry-pick', 'apply'],
90 npm: ['install', 'ci', 'test', 'run', 'exec', 'publish', 'audit', 'update', 'uninstall'],
91 pnpm: ['install', 'add', 'test', 'run', 'exec', 'update', 'remove'],
92 yarn: ['install', 'add', 'test', 'run', 'remove', 'upgrade'],
93 bun: ['install', 'add', 'test', 'run', 'x'],
94 cargo: ['build', 'test', 'check', 'clippy', 'run', 'fmt', 'add'],
95 go: ['build', 'test', 'run', 'vet', 'mod', 'get'],
96 docker: ['build', 'run', 'compose', 'push', 'pull', 'exec'],
97 gh: ['pr', 'issue', 'api', 'run', 'repo', 'stack'],
98 pip: ['install', 'uninstall'],
99 pip3: ['install', 'uninstall'],
100 uv: ['run', 'add', 'sync', 'pip'],
101 poetry: ['install', 'add', 'run'],
102 swift: ['build', 'test', 'run'],
103 pod: ['install', 'update'],
104 expo: ['start', 'run', 'prebuild', 'install'],
105}
106
107// How the engine reports a call that was stopped rather than one that failed.
108const ABORTED =
109 /Command was aborted before completion|\[Request interrupted|Interrupted by user|\[Tool call (did not complete|interrupted|skipped|not completed)/i
110
111// The Edit/Write tools fold CRLF to LF in their records; compare files the same way.
112const lf = (text: string) => text.replace(/\r\n/g, '\n')
113
114const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
115
116// A short, safe description: the program and its subcommand, or the tool and
117// a file name or host. Never an argument value, URL path, query or search text.
118export function safeLabel(e: Call): string {
119 if (e.tool === 'Bash') {
120 const words = String(e.command ?? '').trim().split(/\s+/)
121 const program = basename(words[0] ?? '')
122 const sub = words[1] ?? ''
123 const head = /^[A-Za-z0-9._-]{1,30}$/.test(program) ? program : 'command'
124 // A subcommand only for tools whose second word is a verb, never a value.
125 const showSub = (VERBS[head] ?? []).includes(sub)
126 return showSub ? `${head} ${sub}${words.length > 2 ? ' …' : ''}` : `${head}${words.length > 1 ? ' …' : ''}`
127 }
128 const path = e.file_path ?? e.notebook_path
129 if (typeof path === 'string') return `${e.tool} ${basename(path)}`
130 if (typeof e.url === 'string') {
131 try {
132 return `${e.tool} ${new URL(e.url).hostname}`
133 } catch {
134 return String(e.tool)
135 }
136 }
137 return String(e.tool)
138}
139
140const errorText = (ran: { text?: string; result?: unknown }) =>
141 typeof ran.text === 'string' && ran.text !== '' ? ran.text : stable(ran.result ?? null)
142
143const isPerson = (origin: { kind: string; asUser?: true }) =>
144 PERSON.has(origin.kind) || (origin.kind === 'plugin' && origin.asUser === true)
145
146// The file text an Edit produces from `text`, as the Edit tool applies it.
147function applyEdit(text: string, e: Call): string | null {
148 const from = String(e.old_string)
149 const to = String(e.new_string)
150 if (from === '' || !text.includes(from)) return null
151 return e.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to)
152}
153
154// The file's text before and after a successful Edit or Write, when the result says.
155function versions(e: Call, result: Record<string, unknown>): { before: string; after: string } | null {
156 if (typeof result.originalFile !== 'string') return null
157 const before = lf(result.originalFile)
158 if (e.tool === 'Write') return { before, after: lf(typeof result.content === 'string' ? result.content : String(e.content)) }
159 if (e.tool === 'Edit') {
160 // The person changed the proposal: the bytes on disk are not ours to predict.
161 if (result.userModified === true) return null
162 const after = applyEdit(before, e)
163 return after === null ? null : { before, after }
164 }
165 return null
166}
167
168// Paths a call changed on disk; null when it changed nothing or cannot be told.
169// An empty list means files changed but the host could not say which.
170function changedPaths(e: Call, result: Record<string, unknown>, failed: boolean): string[] | null {
171 if (e.tool === 'Bash') {
172 const diff = result.bashEditDiff as
173 | { files?: { filePath: string }[]; changedFiles?: string[]; unavailable?: true; skipped?: true }
174 | undefined
175 if (diff === undefined) return null
176 const paths = [...(diff.files ?? []).map(f => f.filePath), ...(diff.changedFiles ?? [])]
177 if (paths.length > 0) return [...new Set(paths)]
178 // Skipped for checkout/stash/reset/restore, which rewrite the tree; "unavailable" proves nothing.
179 return diff.skipped === true ? [] : null
180 }
181 // An errored call's result is its error text: no record says anything changed.
182 if (failed) return null
183 if (e.tool === 'Edit' || e.tool === 'Write') {
184 if (result.staged === true) return null
185 const v = versions(e, result)
186 if (v !== null && v.before === v.after) return null
187 return [String(e.file_path)]
188 }
189 if (e.tool === 'NotebookEdit') {
190 if (typeof result.error === 'string' && result.error !== '') return null
191 if (typeof result.original_file === 'string' && result.original_file === result.updated_file) return null
192 return [String(e.notebook_path)]
193 }
194 return null
195}
196
197// Writes the meter. A newer show may run while this set is in flight; whichever
198// finishes last writes the latest value again, so the band ends on the truth.
199async function show($: EngineInterface, next: Stuck | null) {
200 shown = next
201 await $.state.set(stuckRef, next)
202 if (shown !== next) await $.state.set(stuckRef, shown)
203}
204
205// Whether the meter's claim still holds against the history.
206function stillStuck(cur: Stuck | null): Stuck | null {
207 if (cur === null) return null
208 const loop = loops.get(cur.agent)
209 if (loop === undefined) return null
210 if (cur.kind === 'repeat') return (loop.streaks.get(cur.fp)?.count ?? 0) >= MAX_FAILS ? cur : null
211 return (loop.chains.get(cur.fp)?.length ?? 0) >= MAX_CHAIN ? cur : null
212}
213
214async function reset($: EngineInterface) {
215 gen += 1
216 loops.clear()
217 await show($, null)
218}
219
220// Would this call put the file back to its version before the last change, again?
221async function undoesAgain($: EngineInterface, e: Call, chain: Chain | undefined): Promise<boolean> {
222 if (chain === undefined || chain.length < MAX_CHAIN) return false
223 if (e.tool !== 'Edit' && e.tool !== 'Write') return false
224 let current: string
225 try {
226 current = lf(await $.fs.read(String(e.file_path)))
227 } catch {
228 return false
229 }
230 // Someone else changed the file since: the chain no longer describes it.
231 if ((await hash(current)) !== chain.to) return false
232 const after = e.tool === 'Write' ? lf(String(e.content)) : applyEdit(current, e)
233 return after !== null && (await hash(after)) === chain.from
234}
235
236export const register: Register = on => {
237 on('session.start', async ($, e, next) => {
238 // A reload starts the history over; a meter left in state no longer applies.
239 await $.state.set(stuckRef, null)
240 await $.command.register({
241 name: 'unstick',
242 description: 'Loop Breaker: forget recorded failures and let blocked calls run again',
243 immediate: true,
244 })
245 return next(e)
246 })
247
248 on('command.run', { command: 'unstick' }, async $ => {
249 await reset($)
250 return { text: 'Loop Breaker: history cleared. Blocked calls may run again.' }
251 })
252
253 on('prompt.submit', async ($, e, next) => {
254 if (isPerson(e.origin)) await reset($)
255 return next(e)
256 })
257
258 // /clear and resume end the conversation without reloading the module.
259 on('session.end', async ($, e, next) => {
260 gen += 1
261 loops.clear()
262 shown = null
263 try {
264 await $.state.set(stuckRef, null)
265 } catch {
266 // The session is going away; the module state above is what matters.
267 }
268 return next(e)
269 })
270
271 // A subagent that finished cannot loop any more: drop its history and meter.
272 on('turn.complete', async ($, e, next) => {
273 if (e.agentId !== undefined) {
274 loops.delete(e.agentId)
275 await show($, stillStuck(shown))
276 }
277 return next(e)
278 })
279
280 on('tool.call', async ($, e, next) => {
281 const call = e as Call
282 const agent = e.agentId ?? 'main'
283 const fp = await hash(identity(call))
284 const startedIn = gen
285 const codeAtStart = codeGen
286 const mine = loopOf(agent)
287 const file = typeof call.file_path === 'string' ? call.file_path : null
288
289 // Rule 1: the same call keeps failing with the same error.
290 const streak = mine.streaks.get(fp)
291 if (streak !== undefined && streak.count >= MAX_FAILS) {
292 blockedCount += 1
293 void $.state.set(blockedRef, blockedCount)
294 await show($, { agent, fp, file: '', label: streak.label, count: streak.count, kind: 'repeat' })
295 return {
296 deny:
297 `Loop Breaker: this exact call has failed ${streak.count} times in a row with the same error, ` +
298 `and nothing has changed since (${streak.label}). Running it unchanged will fail the same way. ` +
299 `Make a change that addresses the error first (edit code, fix config, install what is ` +
300 `missing), then rerun it; or ask the user. The user can run /unstick.`,
301 }
302 }
303
304 // Rule 2: a change that puts the whole file back to the version before the last one, again.
305 if (file !== null) {
306 const chain = mine.chains.get(file)
307 const again = await undoesAgain($, call, chain)
308 // A reset or another change while the file was read: the chain is not current.
309 if (again && gen === startedIn && mine.chains.get(file) === chain) {
310 blockedCount += 1
311 void $.state.set(blockedRef, blockedCount)
312 await show($, { agent, fp: file, file, label: `${call.tool} ${basename(file)}`, count: chain?.length ?? 0, kind: 'revert' })
313 return {
314 deny:
315 `Loop Breaker: this ${call.tool} puts ${file} back to the version it had before the last ` +
316 `change, and the file has flipped between those two versions ${chain?.length ?? 0} times in a ` +
317 `row. You are oscillating. Decide which is right from evidence (run the test, read the ` +
318 `error), say why, then make one different change, or ask the user. The user can run /unstick.`,
319 }
320 }
321 }
322
323 const ran = await next(e)
324 if (ran.deny !== undefined) return ran
325 // A reset landed while the tool ran: this call belongs to the old history.
326 if (gen !== startedIn) return ran
327
328 const result = (ran.result !== null && typeof ran.result === 'object' ? ran.result : {}) as Record<string, unknown>
329 const failed = ran.isError === true || (call.tool === 'NotebookEdit' && typeof result.error === 'string' && result.error !== '')
330 // Interrupted or moved to the background: no verdict on whether it works,
331 // though what it already wrote still counts.
332 const undecided =
333 result.interrupted === true ||
334 typeof result.backgroundTaskId === 'string' ||
335 (failed && ABORTED.test(errorText(ran)))
336
337 // Changes on disk count whether or not the call then failed (when the record says).
338 const changed = changedPaths(call, result, failed)
339 const swap = !failed && changed !== null && file !== null ? versions(call, result) : null
340 const from = swap !== null ? await hash(swap.before) : ''
341 const to = swap !== null ? await hash(swap.after) : ''
342 const error = failed ? await hash(normalizeError(errorText(ran))) : ''
343 if (gen !== startedIn) return ran
344
345 if (changed !== null) {
346 codeGen += 1
347 // The code changed: every agent's failures may now go differently, and
348 // those files' chains no longer describe them (except the one this call extends).
349 for (const loop of loops.values()) {
350 loop.streaks.clear()
351 for (const key of [...loop.chains.keys()]) {
352 const ours = loop === mine && key === file && swap !== null
353 if (!ours && (changed.length === 0 || changed.some(p => key === p))) loop.chains.delete(key)
354 }
355 }
356 }
357
358 if (undecided) {
359 // No verdict either way.
360 } else if (failed) {
361 // A failure of code that has since changed says nothing about the code now.
362 if (codeGen === codeAtStart || changed !== null) {
363 const was = mine.streaks.get(fp)
364 const count = was !== undefined && was.error === error ? was.count + 1 : 1
365 mine.streaks.set(fp, { label: safeLabel(call), count, error })
366 }
367 } else {
368 mine.streaks.delete(fp)
369 }
370
371 if (file !== null && changed !== null) {
372 if (swap === null) {
373 // The result does not say what the whole file was (too large, or a notebook).
374 mine.chains.delete(file)
375 } else {
376 const was = mine.chains.get(file)
377 const undoes = was !== undefined && was.from === to && was.to === from
378 mine.chains.set(file, { from, to, length: undoes ? was.length + 1 : 1 })
379 }
380 }
381
382 // The meter: show the streak one failure before anything is refused; clear it once it ends.
383 const now = mine.streaks.get(fp)
384 if (now !== undefined && now.count >= MAX_FAILS) {
385 await show($, { agent, fp, file: '', label: now.label, count: now.count, kind: 'repeat' })
386 } else {
387 await show($, stillStuck(shown))
388 }
389 return ran
390 })
391
392 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
393 const now = await read($, stuck)
394 // The band is one site: draw ours above whatever the plugins beneath draw.
395 const below = await next(e)
396 if (now === null || e.props.hasSurvey || stillStuck(now) === null) return below
397 const { Box, Text, Button } = $.ui.resolve(e)
398 const what = now.kind === 'repeat' ? `failed ${now.count}× in a row` : `flipped ${now.count}×`
399 const who = now.agent === 'main' ? '' : ' (subagent)'
400 return (
401 <Box flexDirection="column">
402 <Box>
403 <Text color="yellow">⟳ stuck{who}: </Text>
404 <Text wrap="truncate-end">{now.label} </Text>
405 <Text dimColor>{what} </Text>
406 <Button key="unstick" label="unstick" onPress={() => reset($)} />
407 </Box>
408 {below}
409 </Box>
410 )
411 })
412}
413types/index.d.ts 13 lines1// What the band above the prompt shows. The loop history itself lives in the
2// module (one environment, so parallel tool calls never race on a snapshot).
3export type Stuck = { agent: string; fp: string; file: string; label: string; count: number; kind: 'repeat' | 'revert' }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'loop-breaker': {
8 stuck: Stuck | null
9 blocked: number
10 }
11 }
12}
13