Stops the same call failing the same way again and again: warns at LOOP 2/3, blocks the next identical run once sealed, lifts after a change or a new message

A loop is the same call failing the same way, again and again, with nothing changed in between. The loop breaker counts those and steps in:
| Identical failure | What happens |
|---|---|
| 2nd | LOOP 2/3: Claude is told running it again unchanged will not help |
| 3rd | LOOP 3: Claude is told the next identical run will be blocked |
| 4th run | refused (block) or let through with a note (warn) |
"Identical" ignores timings, timestamps, temp paths and addresses, but keeps counts: 2 failed → 1 failed is progress, not a loop.
The seal lifts when something changes:
/loop-breaker reset.Tracked calls: Bash commands, Edit/Write/NotebookEdit per file, WebFetch per URL.
Claude receives:
game-loop-breaker blocked this call: `npm test` already failed the same way 3 times in a row ("FAIL src/auth.test.ts").
Running it again unchanged will not help. Change the code or the command first, try a smaller check, or ask the user.
The block lifts after a change, when the user sends a new message, or when the user types /loop-breaker pass.
Real run: examples/RUN-2026-10-06.md.
Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-loop-breaker@bitgame-mods
mode (block) | block · warn · off |
limit (3) | identical failures before the seal (2–10) |
/loop-breaker | status and open loops |
/loop-breaker reset | clear every streak |
/loop-breaker block · warn · off | change the mode; remembered |
/loop-breaker pass | let the next refused call run once (within 10 minutes) |
warn, off and pass are accepted only when you type them.
curl localhost:3000 failing the same way while it boots) looks like a loop. Wrap the wait in one command (until curl -sf …; do sleep 1; done), raise limit, or use warn.같은 명령이 아무것도 바뀌지 않은 채 같은 방식으로 계속 실패하면 2번째에 경고, 3번째에 봉인 예고, 4번째 실행을 막습니다. 파일을 수정하거나 사용자가 새 메시지를 보내면 풀립니다.
hooks/register.ts 232 lines1import type { Register } from 'claude-code'
2
3// GAME MODE · LOOP BREAKER
4//
5// A loop is the same call failing the same way, again and again, with
6// nothing changed in between. The loop breaker counts those:
7// 2nd identical failure LOOP 2/3: Claude is told to change something
8// 3rd identical failure LOOP 3/3: Claude is told the next one is blocked
9// 4th identical run refused (mode block) or let through with a note
10// (mode warn)
11// "Identical" ignores timings and temp paths but keeps counts, so
12// "2 failed" → "1 failed" is progress, not a loop.
13//
14// What counts as a change: a successful Edit/Write (for commands) or a
15// successful Read of the file (for an Edit that keeps missing its text)
16// takes the call one strike back from the block. A new message from the
17// user clears everything. /loop-breaker pass lets one refused call run.
18
19type Mode = 'block' | 'warn' | 'off'
20
21type Key = { id: string; label: string; kind: 'command' | 'file' }
22
23type Streak = { signature: string; count: number; line: string; label: string; kind: Key['kind'] }
24
25const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
26const PASS_MS = 10 * 60 * 1000
27
28export const register: Register = (on, options) => {
29 let mode: Mode = modeOf(options.mode)
30 const limit = Math.max(2, Math.min(10, Math.round(Number(options.limit ?? 3)) || 3))
31 const streaks = new Map<string, Streak>()
32 let passUntil = 0
33
34 on('session.start', async ($, e, next) => {
35 // A reload starts with no streaks, so a LOOP line kept from before it is stale.
36 $.ui.status(undefined)
37 const stored = await $.store.get('mode')
38 if (stored === 'block' || stored === 'warn' || stored === 'off') mode = stored
39 await $.command.register({
40 name: 'loop-breaker',
41 description: 'GAME MODE loop breaker: status, reset, block, warn, off, or pass the next refused call once',
42 argumentHint: '[reset|block|warn|off|pass]',
43 })
44 return next(e)
45 })
46
47 // New words from the person are new information: every streak starts over.
48 // The line is cleared even with no streak held: one may be left from before a reload.
49 on('prompt.submit', async ($, e, next) => {
50 if (e.origin?.kind !== 'task-notification') {
51 streaks.clear()
52 $.ui.status(undefined)
53 }
54 return next(e)
55 }).catch(($, e, next) => next(e))
56
57 on('tool.call', async ($, e, next) => {
58 if (mode === 'off') return next(e)
59 const tool = String(e.tool)
60 const input = e as unknown as Record<string, unknown>
61 const key = keyOf(tool, input)
62
63 if (key !== undefined) {
64 const held = streaks.get(key.id)
65 if (held !== undefined && held.count >= limit && mode === 'block') {
66 if ((await $.clock.now()) < passUntil) {
67 passUntil = 0
68 $.ui.log(`passed once by /loop-breaker pass — ${key.label}`)
69 } else {
70 $.ui.toast(`LOOP! ${key.label} 봉인 — 같은 실패 ${held.count}번 · /loop-breaker pass`)
71 $.ui.log(`LOOP! blocked ${key.label} after ${held.count} identical failures`)
72 return { deny: denyText(held) }
73 }
74 }
75 }
76
77 const ran = await next(e)
78 if (ran.deny !== undefined) return ran
79
80 if (ran.isError !== true) {
81 // A change of state: one strike back for what it may have fixed.
82 if (WRITES.has(tool)) easeAll(streaks, 'command', limit)
83 if (tool === 'Read') ease(streaks, fileKey('Edit', str(input, 'file_path')), limit)
84 if (key !== undefined && streaks.delete(key.id) && streaks.size === 0) $.ui.status(undefined)
85 return ran
86 }
87 if (key === undefined) return ran
88
89 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
90 const signature = signatureOf(text)
91 const prev = streaks.get(key.id)
92 const count = prev !== undefined && prev.signature === signature ? prev.count + 1 : 1
93 const streak: Streak = { signature, count, line: lineOf(text), label: key.label, kind: key.kind }
94 streaks.set(key.id, streak)
95
96 if (count < limit - 1) return ran
97 $.ui.status(`LOOP ${Math.min(count, limit)}/${limit} · ${key.label}`)
98 const note = count >= limit ? sealedText(streak, mode) : warnText(streak, limit)
99 return { ...ran, context: [...(ran.context ?? []), note] }
100 }).catch(($, e, next) =>
101 next.called ? next(e) : { deny: 'game-loop-breaker: the guard failed while checking this call, so it was stopped. Ask the user to check /loop-breaker.' },
102 )
103
104 on('command.run', { command: 'loop-breaker' }, async ($, e) => {
105 const sub = e.args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
106 const byPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
107
108 if (sub === 'reset') {
109 streaks.clear()
110 $.ui.status(undefined)
111 return { text: 'loop breaker: every streak cleared' }
112 }
113 if (sub === 'block' || sub === 'warn' || sub === 'off') {
114 if (sub !== 'block' && !byPerson) return { text: 'Only you can loosen the loop breaker: type the command yourself.' }
115 mode = sub
116 await $.store.set('mode', mode)
117 return { text: `loop breaker: ${mode}` }
118 }
119 if (sub === 'pass') {
120 if (!byPerson) return { text: 'Only you can pass a refused call: type /loop-breaker pass yourself.' }
121 passUntil = (await $.clock.now()) + PASS_MS
122 return { text: 'The next call the loop breaker would refuse runs once (within 10 minutes).' }
123 }
124 if (sub !== '') return { text: `Unknown option "${sub}". Use reset, block, warn, off or pass.` }
125
126 const open = [...streaks.values()].filter(s => s.count > 1)
127 return {
128 text: [
129 `loop breaker: ${mode} · seal at ${limit} identical failures`,
130 ...(open.length === 0 ? ['no loops'] : open.map(s => ` LOOP ${Math.min(s.count, limit)}/${limit} ${s.label} — ${s.line}`)),
131 '/loop-breaker reset · block · warn · off · pass',
132 ].join('\n'),
133 }
134 })
135}
136
137function modeOf(value: unknown): Mode {
138 return value === 'warn' || value === 'off' ? value : 'block'
139}
140
141function str(input: Record<string, unknown>, key: string): string | undefined {
142 const value = input[key]
143 return typeof value === 'string' && value.length > 0 ? value : undefined
144}
145
146function fileKey(tool: string, path: string | undefined): string | undefined {
147 return path === undefined ? undefined : `${tool === 'MultiEdit' ? 'Edit' : tool}:${path}`
148}
149
150export function keyOf(tool: string, input: Record<string, unknown>): Key | undefined {
151 if (tool === 'Bash') {
152 const command = (str(input, 'command') ?? '').replace(/\s+/g, ' ').trim()
153 if (command === '') return undefined
154 return { id: `Bash:${command}`, label: command.length > 48 ? command.slice(0, 47) + '…' : command, kind: 'command' }
155 }
156 if (WRITES.has(tool)) {
157 const path = str(input, 'file_path') ?? str(input, 'notebook_path')
158 const id = fileKey(tool, path)
159 if (id === undefined || path === undefined) return undefined
160 return { id, label: `${tool.toUpperCase()} ${path.split(/[\\/]/).pop()}`, kind: 'file' }
161 }
162 if (tool === 'WebFetch') {
163 const url = str(input, 'url')
164 return url === undefined ? undefined : { id: `WebFetch:${url}`, label: `FETCH ${url}`, kind: 'command' }
165 }
166 return undefined
167}
168
169function ease(streaks: Map<string, Streak>, id: string | undefined, limit: number): void {
170 const s = id === undefined ? undefined : streaks.get(id)
171 if (s !== undefined && s.count >= limit) s.count = limit - 1
172}
173
174function easeAll(streaks: Map<string, Streak>, kind: Key['kind'], limit: number): void {
175 for (const s of streaks.values()) if (s.kind === kind && s.count >= limit) s.count = limit - 1
176}
177
178/** The failure, minus what changes between identical runs (timings, temp paths, addresses). */
179export function signatureOf(text: string): string {
180 const clean = text
181 .replace(/\u001b\[[0-9;]*m/g, '')
182 .replace(/\b\d{4}-\d\d-\d\d[T ]\d\d:\d\d(?::\d\d(?:\.\d+)?)?Z?\b/g, '<time>')
183 .replace(/\b\d+(?:\.\d+)?\s?(?:ms|s|sec|secs|seconds|m|min)\b/g, '<dur>')
184 .replace(/\b0x[0-9a-f]+\b/gi, '<hex>')
185 .replace(/(?:\/private)?\/(?:tmp|var\/folders)\/\S+/g, '<tmp>')
186 .replace(/\s+/g, ' ')
187 .trim()
188 return clean.length > 1600 ? clean.slice(0, 800) + ' … ' + clean.slice(-800) : clean
189}
190
191/** The line that best says what failed. */
192export function lineOf(text: string): string {
193 const lines = text
194 .replace(/\u001b\[[0-9;]*m/g, '')
195 .split('\n')
196 .map(l => l.trim())
197 .filter(l => l.length > 0 && !/^Exit code \d+$/.test(l))
198 const best = lines.find(l => /error|fail|cannot|not found|exception|denied/i.test(l)) ?? lines[0] ?? '(no output)'
199 return best.length > 120 ? best.slice(0, 119) + '…' : best
200}
201
202function times(n: number): string {
203 return n === 1 ? 'once' : `${n} times`
204}
205
206function warnText(s: Streak, limit: number): string {
207 return (
208 `game-loop-breaker: LOOP ${s.count}/${limit} — \`${s.label}\` failed the same way ${times(s.count)} in a row ("${s.line}"). ` +
209 'Running it again unchanged will not help: read the error, change something, or try a smaller check. ' +
210 'One more identical failure and it will be blocked.'
211 )
212}
213
214function sealedText(s: Streak, mode: Mode): string {
215 return (
216 `game-loop-breaker: LOOP ${s.count} — \`${s.label}\` failed the same way ${times(s.count)} in a row ("${s.line}"). ` +
217 (mode === 'block'
218 ? 'The next identical run will be blocked until something changes. '
219 : 'This is a loop. ') +
220 'Stop and say what you tried; then change the approach or ask the user.'
221 )
222}
223
224function denyText(s: Streak): string {
225 const change = s.kind === 'file' ? 'Read the file again to see its current text' : 'Change the code or the command first'
226 return (
227 `game-loop-breaker blocked this call: \`${s.label}\` already failed the same way ${times(s.count)} in a row ("${s.line}"). ` +
228 `Running it again unchanged will not help. ${change}, try a smaller check, or ask the user. ` +
229 'The block lifts after a change, when the user sends a new message, or when the user types /loop-breaker pass.'
230 )
231}
232