SLOPSHOPPER

loop-breaker

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.

newguardtoaststatusprompt
v0.1.0no licenseupdated 2026-10-09zainokta/zstack/mods/loop-breaker
A shopper browsing a rack in a slop shop
README

loop-breaker

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.

  • 2nd time: a toast, plus a hidden user-role row ($.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.
  • 3rd time and after: the status line reads loop: same failure Nx — diagnose, don't patch.
  • When the same command head succeeds later, its signatures reset.
  • Runs the user interrupted or refused do not count.

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.

Source 2 files
hooks/register.ts 148 lines
1import { 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}
148
types/index.d.ts 11 lines
1export 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