한 턴에 같은 명령이 되풀이해 실패하거나 이미 고친 코드를 거듭 다시 고치면, 토스트로 알리고 Claude에게 멈추고 원인을 다시 보라고 한 줄을 붙인다

Claude가 같은 자리를 맴돌면 멈추게 한다. 모델을 부르지 않는다.
failLimit번(기본 3) 실패하면 알린다. 그 명령이 한 번 성공하면 셈을 지운다.editLimit번(기본 3) 다시 고치면 알린다. 한 파일의 다른 곳을 고치는 건 세지 않는다. Windows에서 슬래시나 대소문자만 다른 경로(C:\a\b.ts, c:/a/b.ts)는 같은 파일로 센다./plugin install loop-guard --marketplace vananaHope/vanana-modshooks/register.ts 41 lines1// 같은 명령이 되풀이해 실패하거나 같은 코드를 거듭 다시 고치면, 사람에게는 토스트로, Claude에게는 도구 결과 뒤 한 줄로 알린다.
2import type { EngineInterface, Register } from 'claude-code'
3
4import { LoopWatch } from './loops.ts'
5import type { Nudge } from './loops.ts'
6
7type Options = { failLimit?: number; editLimit?: number }
8
9/** Windows의 PowerShell 도구는 다른 플랫폼의 타입에 없어서 이름 대신 정규식으로 맞춘다 */
10const SHELLS = /^(?:Bash|PowerShell)$/
11
12/** 맴돌기를 알린다: 사람에게는 토스트, Claude에게는 도구 결과 뒤에 붙는 한 줄(사람에게는 보이지 않는다) */
13function withNudge<T extends { context?: readonly string[] }>($: EngineInterface, ran: T, nudge: Nudge | undefined): T {
14 if (!nudge) return ran
15 $.ui.toast(`▲ ${nudge.toast}`, { timeoutMs: 9000 })
16 return { ...ran, context: [...(ran.context ?? []), `<loop-guard>${nudge.note}</loop-guard>`] }
17}
18
19export const register: Register = (on, options) => {
20 const { failLimit = 3, editLimit = 3 } = (options ?? {}) as Options
21 const loops = new LoopWatch(Math.max(2, Number(failLimit) || 3), Math.max(2, Number(editLimit) || 3))
22
23 on('turn.start', ($, e, next) => {
24 loops.startTurn(e.agentId)
25 return next(e)
26 })
27
28 on('tool.call', { tool: SHELLS }, async ($, e, next) => {
29 const ran = await next(e)
30 if (ran.deny !== undefined || !('command' in e) || typeof e.command !== 'string') return ran
31 return withNudge($, ran, loops.command(e.command, ran.isError === true, e.agentId))
32 })
33
34 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
35 const ran = await next(e)
36 if (ran.deny !== undefined || ran.isError === true) return ran
37 const input = e as unknown as { file_path: string; old_string?: string; new_string?: string; content?: string }
38 return withNudge($, ran, loops.edit(input.file_path, input.old_string, input.new_string ?? input.content ?? '', e.agentId))
39 })
40}
41hooks/loops.ts 92 lines1// 같은 자리를 맴도는지 세는 순수 로직. mishgoldenberg/claude-mods의 loop-breaker(MIT, Michael Goldenberg)를 바탕으로 고쳐 썼다.
2// 바꾼 점: 메인 대화와 서브에이전트를 따로 센다. 한 쪽의 턴이 시작돼도 다른 쪽 셈은 지우지 않는다.
3
4/** 12자 이상인 의미 있는 줄만: 괄호나 빈 줄이 겹친다고 같은 코드로 보지 않는다 */
5const lines = (text: string) => text.split('\n').map(l => l.trim()).filter(l => l.length >= 12)
6
7export const normCommand = (command: string) => command.replace(/\s+/g, ' ').trim().slice(0, 300)
8
9/**
10 * 같은 파일을 한 이름으로: Windows에서는 C:\a\b.ts와 c:/a/b.ts가 같은 파일이다.
11 * 드라이브 글자로 시작하는 경로만 대소문자를 무시한다(다른 플랫폼은 대소문자를 가린다).
12 */
13export const normPath = (path: string) => {
14 const slashed = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
15 return /^[A-Za-z]:\//.test(slashed) ? slashed.toLowerCase() : slashed
16}
17
18type Loop = {
19 failures: Map<string, number>
20 reworks: Map<string, number>
21 written: Map<string, Set<string>>
22 nudged: Set<string>
23}
24
25const fresh = (): Loop => ({ failures: new Map(), reworks: new Map(), written: new Map(), nudged: new Set() })
26
27export type Nudge = { toast: string; note: string }
28
29export class LoopWatch {
30 private loops = new Map<string, Loop>()
31
32 constructor(private failLimit: number, private editLimit: number) {}
33
34 private loop(agentId?: string): Loop {
35 const key = agentId ?? 'main'
36 let loop = this.loops.get(key)
37 if (!loop) this.loops.set(key, (loop = fresh()))
38 return loop
39 }
40
41 /** 그 대화의 새 턴: 셈을 처음부터 */
42 startTurn(agentId?: string) {
43 this.loops.set(agentId ?? 'main', fresh())
44 }
45
46 /** 한 번 알린 맴돌기는 그 턴에 다시 알리지 않는다 */
47 private once(loop: Loop, id: string, nudge: Nudge): Nudge | undefined {
48 if (loop.nudged.has(id)) return undefined
49 loop.nudged.add(id)
50 return nudge
51 }
52
53 command(command: string, isError: boolean, agentId?: string): Nudge | undefined {
54 const loop = this.loop(agentId)
55 const key = normCommand(command)
56 if (!isError) {
57 loop.failures.delete(key)
58 return undefined
59 }
60 const n = (loop.failures.get(key) ?? 0) + 1
61 loop.failures.set(key, n)
62 if (n < this.failLimit) return undefined
63 return this.once(loop, `fail:${key}`, {
64 toast: `같은 명령이 ${n}번 실패했습니다: ${key.slice(0, 50)}`,
65 note:
66 `The command \`${key.slice(0, 200)}\` has now failed ${n} times this turn. Stop retrying it. ` +
67 'Re-read the full error output, state the root cause in one sentence, and either try a genuinely different approach or ask the user.',
68 })
69 }
70
71 /** old: Edit의 old_string(Write면 undefined), next: 새로 쓴 내용 */
72 edit(path: string, old: string | undefined, next: string, agentId?: string): Nudge | undefined {
73 const loop = this.loop(agentId)
74 const key = normPath(path)
75 const seen = loop.written.get(key)
76 // 한 파일의 여러 곳을 고치는 건 계획이고, 이번 턴에 이미 고친 코드를 또 고치는 건 맴돌기다
77 const isRework = seen !== undefined && (old === undefined || lines(old).some(l => seen.has(l)))
78 loop.written.set(key, new Set([...(seen ?? []), ...lines(next)]))
79 if (!isRework) return undefined
80 const n = (loop.reworks.get(key) ?? 0) + 1
81 loop.reworks.set(key, n)
82 if (n < this.editLimit) return undefined
83 const name = path.split(/[\\/]/).at(-1) ?? path
84 return this.once(loop, `edit:${key}`, {
85 toast: `${name}: 이번 턴에 같은 코드를 ${n}번 다시 고쳤습니다`,
86 note:
87 `You have now rewritten code you already changed in ${path} ${n} times this turn. ` +
88 "Pause: re-read the whole file as it is now, work out why the earlier attempts didn't hold, and make one deliberate change instead of more patches.",
89 })
90 }
91}
92