SLOPSHOPPER

맴돌기 막기

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

newguardtoast
A shopper browsing a rack in a slop shop
README

loop-guard (맴돌기 막기)

Claude가 같은 자리를 맴돌면 멈추게 한다. 모델을 부르지 않는다.

  • 한 턴에 같은 셸 명령(Bash·PowerShell)이 failLimit번(기본 3) 실패하면 알린다. 그 명령이 한 번 성공하면 셈을 지운다.
  • 한 턴에 이미 고친 코드를 editLimit번(기본 3) 다시 고치면 알린다. 한 파일의 다른 곳을 고치는 건 세지 않는다. Windows에서 슬래시나 대소문자만 다른 경로(C:\a\b.ts, c:/a/b.ts)는 같은 파일로 센다.
  • 알림은 사람에게는 토스트, Claude에게는 도구 결과 뒤에 붙는 한 줄(원인을 다시 보고 다른 방법을 쓰거나 사용자에게 물으라는 말)이다. 한 맴돌기에 한 번만 알린다.
  • 메인 대화와 서브에이전트를 따로 센다.

설치

/plugin install loop-guard --marketplace vananaHope/vanana-mods
Source 2 files
hooks/register.ts 41 lines
1// 같은 명령이 되풀이해 실패하거나 같은 코드를 거듭 다시 고치면, 사람에게는 토스트로, 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}
41
hooks/loops.ts 92 lines
1// 같은 자리를 맴도는지 세는 순수 로직. 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