Notices the same failure coming back (a Bash command failing the same way, or the same error pasted again) and tells Claude to stop patching and diagnose.

Sessions kept getting stuck on one failed fix, repeated 5+ times ("still same ahh", "oof, same", about 40 pasted logs in one session) with no diagnosis. This mod notices when the same failure comes back and tells Claude to stop patching and diagnose.
Bash failures (tool.call). When a call fails, the mod builds a signature: the command head (pnpm test, python pytest) plus the first error line, with paths, numbers, hashes and long tokens stripped. It counts each signature in $.state.
$.session.append, into the subagent's loop when a subagent made the call). The row tells Claude to reproduce, list hypotheses with discriminating checks, find the root cause with evidence, and only then fix.loop: same failure Nx — diagnose, don't patch.Pasted errors (prompt.submit). Error lines in a prompt are compared with the last pasted error. If at least half of them recur, or the prompt is a short "still same" or "oof, same", the prompt gets the same diagnosis note as context.
Fails open. It only adds advice, so a broken hook just lets the call or prompt through.
Limits: exit-0 commands that print errors (for example cmd | tail) are not counted. The same bug can still escape a signature if the failing command or its first error line changes. Only the last pasted error is remembered. The session.append row is written before the engine stores that call's tool result. The types do not document how the two rows are ordered, so this needs checking in a live session; the result's context would be the ordering-safe alternative.
hooks/register.ts 148 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LoopFailure, LoopPaste } from '../types'
5
6const failures = atom({ plugin: 'loop-breaker', key: 'failures' } as const, {} as Record<string, LoopFailure>)
7const paste = atom({ plugin: 'loop-breaker', key: 'paste' } as const, null as LoopPaste | null)
8
9// Lines that say nothing about which failure this is.
10const NOISE = /^(exit code \d+|traceback \(most recent call last\)|at\s|\^+$|-+$|npm err! (a complete log|this is probably))/i
11const ERROR_WORD =
12 /\b(error|exception|fatal|panic|failed|failure|cannot|can't|unable|denied|not found|no such|undefined|refused|timed? ?out|invalid|unexpected|missing)\b/i
13const ERROR_TYPE = /\b[A-Z]\w*(Error|Exception)\b/
14
15// A run the user stopped or refused is not a failure of the fix.
16const NOT_A_FAILURE = /interrupted|doesn't want to proceed|was rejected/i
17
18// "still same ahh", "oof, same", "same error again", "still failing".
19const STILL_SAME =
20 /\b(still|again)\b[^.\n]{0,30}\b(same|fail\w*|error|broken|not work\w*)\b|\bsame\b[^.\n]{0,15}\b(ahh|error|issue|again|thing|problem|result|output)\b|^\W*(\w+\W+)?same\W*$/i
21
22const WRAPPERS = new Set(['sudo', 'time', 'env', 'nohup', 'command', 'exec'])
23const SETUP = /^(cd|pushd|popd|export|source|set|\.)$/
24
25export function normalize(line: string): string {
26 return line
27 .replace(/[\w.~-]*\/[\w./-]+/g, '<path>')
28 .replace(/\b[0-9a-f]{7,}\b/gi, '<hash>')
29 .replace(/[\w+/=-]{24,}/g, '<token>')
30 .replace(/\d+/g, 'N')
31 .replace(/\s+/g, ' ')
32 .trim()
33 .toLowerCase()
34 .slice(0, 160)
35}
36
37const isErrorLine = (line: string) => ERROR_WORD.test(line) || ERROR_TYPE.test(line)
38
39// The command's program and, when it is a plain word, its subcommand: `pnpm test`, `python pytest`.
40export function commandHead(command: string): string {
41 for (const segment of command.split(/\n|&&|\|\||[;|]/)) {
42 const words = segment.trim().split(/\s+/).filter(w => w !== '' && !/^\w+=/.test(w))
43 while (words.length > 0 && WRAPPERS.has(words[0] ?? '')) {
44 words.shift()
45 }
46 const program = words[0]
47 if (program === undefined || SETUP.test(program)) {
48 continue
49 }
50 const sub = words.slice(1).find(w => !w.startsWith('-'))
51 const name = program.split('/').pop() ?? program
52 return sub !== undefined && /^[a-z][\w:.-]*$/.test(sub) ? `${name} ${sub}` : name
53 }
54 return command.trim().slice(0, 40)
55}
56
57// The first line that names the failure, normalized so a rerun matches it.
58export function errorLine(output: string): string {
59 const lines = output.split('\n').map(l => l.trim()).filter(l => l !== '' && !NOISE.test(l))
60 return normalize(lines.find(isErrorLine) ?? lines[0] ?? '')
61}
62
63export function errorLines(text: string): string[] {
64 const lines = text.split('\n').map(l => l.trim()).filter(l => l !== '' && !NOISE.test(l) && isErrorLine(l))
65 return [...new Set(lines.map(normalize))].slice(0, 20)
66}
67
68// Two pastes are the same error when at least half of the shorter one's error lines recur.
69const isSameError = (a: readonly string[], b: readonly string[]) =>
70 a.length > 0 && b.length > 0 && a.filter(l => b.includes(l)).length * 2 >= Math.min(a.length, b.length)
71
72const DIAGNOSE =
73 'Stop patching. Run the diagnosis loop before changing more code:\n' +
74 '1. Reproduce it with one command and confirm it fails the same way.\n' +
75 '2. List 2-4 hypotheses for the cause, each with a check that tells it apart from the others.\n' +
76 '3. Run those checks and name the root cause with its evidence (output, code lines).\n' +
77 '4. Only then make one fix, and rerun the reproduction to confirm it.'
78
79async function showStatus($: EngineInterface) {
80 const worst = Object.values(await read($, failures)).reduce((n, f) => Math.max(n, f.count), 0)
81 const pasted = (await read($, paste))?.count ?? 0
82 const count = Math.max(worst, pasted)
83 $.ui.status(count >= 3 ? `loop: same failure ${count}x — diagnose, don't patch` : undefined)
84}
85
86export const register: Register = on => {
87 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
88 const ran = await next(e)
89 if (ran.deny !== undefined) {
90 return ran
91 }
92 const head = commandHead(e.command)
93
94 if (!ran.isError) {
95 const known = await read($, failures)
96 if (Object.values(known).some(f => f.head === head)) {
97 await update($, failures, all => Object.fromEntries(Object.entries(all).filter(([, f]) => f.head !== head)))
98 await showStatus($)
99 }
100 return ran
101 }
102
103 const output = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
104 if (NOT_A_FAILURE.test(output)) {
105 return ran
106 }
107 const error = errorLine(output)
108 const signature = `${head} :: ${error}`
109 const all = await update($, failures, was => ({
110 ...was,
111 [signature]: { head, error, count: (was[signature]?.count ?? 0) + 1 },
112 }))
113 const count = all[signature]?.count ?? 0
114
115 if (count >= 2) {
116 $.ui.toast(`loop-breaker: \`${head}\` failed the same way ${count} times; asked Claude to diagnose`)
117 const text = `loop-breaker: this Bash failure has now happened ${count} times with the same signature (${signature}). ${DIAGNOSE}`
118 await $.session.append({
119 message: { type: 'user', content: [{ type: 'text', text }] },
120 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
121 })
122 await showStatus($)
123 }
124 return ran
125 })
126
127 on('prompt.submit', async ($, e, next) => {
128 const lines = errorLines(e.text)
129 const last = await read($, paste)
130 const isRepeat = last !== null && (lines.length > 0 ? isSameError(lines, last.lines) : STILL_SAME.test(e.text.trim()))
131
132 if (!isRepeat) {
133 if (lines.length > 0) {
134 await update($, paste, () => ({ lines, count: 1 }))
135 await showStatus($)
136 }
137 return next(e)
138 }
139
140 const now = await update($, paste, was => ({ lines: lines.length > 0 ? lines : (was?.lines ?? []), count: (was?.count ?? 0) + 1 }))
141 const count = now?.count ?? 0
142 $.ui.toast(`loop-breaker: the same error is back (${count} times); asked Claude to diagnose`)
143 await showStatus($)
144 const note = `loop-breaker: the user has now reported the same error ${count} times, so the previous fixes did not work. ${DIAGNOSE}`
145 return next({ ...e, context: [...(e.context ?? []), note] })
146 })
147}
148types/index.d.ts 11 lines1export type LoopFailure = { head: string; error: string; count: number }
2
3// The error lines of the last error the user pasted, and how often it came back.
4export type LoopPaste = { lines: string[]; count: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'loop-breaker': { failures: Record<string, LoopFailure>; paste: LoopPaste | null }
9 }
10}
11