Adds a note to the fifth edit of one file in one turn, so the model re-reads the code path and states the root cause instead of trying again.

When a fix does not work, the model tends to edit the same file again, and again, each time with a slightly different guess. Five edits of one file in one turn usually mean it is guessing instead of understanding. This mod notices that moment and tells the model to stop, re-read the code and name the root cause before the next edit. It stops nothing.
edit-loop: 3rd edit of hooks/a.ts in this turn
The model reads nothing at this count.
edit-loop: this turn edited hooks/a.ts 5 times. Stop editing it, re-read the code path and state the root cause before the next edit.
The path is relative to the git repository the session started in when the file is inside it, so a session opened in plugins/a shows a file of plugins/b as plugins/b/x.ts. Outside a git repository the path is relative to the directory the session started in. That root is read once at the session's start, because a Bash cd moves the session's own directory. The note comes once per file and turn; the sixth and later edits get none.
edit-loop: 5th edit of hooks/a.ts in this turn
The note and the line are separate channels: the model never reads the line, and you never read the note.
3rd, 5th) carries the colour, yellow at the third edit and red at the fifth, the path stays in the default colour, and in this turn is faint. An entry stays until newer ones push it off the pane. Without the sidebar, the lines land in the transcript as above.In the live check the model edited one file six times in one turn. It read the note after the fifth edit, re-read the file, explained why the edits were intended, and quoted the note word for word. The other five edits got no note.
/edit-loop on or off /edit-loop on | off on by default
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install edit-loop@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=edit-loop}, turn.start, tool.call{tool=Edit}, tool.call{tool=Write}, tool.call{tool=NotebookEdit} ❯ ./register.ts calls: $.command.register, $.process.run (via shownRootOf), $.session.cwd (via afterEdit, shownRootOf), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand), $.ui.log (via toPerson)
Reach L2: it runs a process.
git rev-parse --show-toplevel once at the session's start, to show paths against the repository rootsed -i, a heredoc, a script) is not counted...) counts twice.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 114 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { countEdit, logText, noteText, sectionKey, shownPath, sidebarLines, THRESHOLD, WARN_THRESHOLD, type Counts, type Line } from './loop.ts'
3
4const ENABLED_KEY = 'enabled'
5
6const USAGE = 'expects nothing (the status), on or off'
7
8/**
9 * The edit counts of the running turn, the on/off setting, and the directory paths are shown against,
10 * read once at the session's start (`shownRootOf`), not from `$.session.cwd()` at each edit, because a
11 * Bash `cd` moves the session's directory and would then leave every path outside it written in full.
12 */
13type State = { counts: Counts; enabled: boolean; root?: string }
14
15/**
16 * Reads the on/off setting from the store, which every window shares, so a change made in another
17 * window applies here at the next hook that acts on it.
18 */
19async function readSettings($: EngineInterface, state: State): Promise<void> {
20 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
21}
22
23/**
24 * The git repository the session started in, so a file in a sibling directory of a session opened in a
25 * subdirectory still reads short; the session's own directory where git does not answer.
26 */
27async function shownRootOf($: EngineInterface): Promise<string> {
28 const cwd = await $.session.cwd()
29 try {
30 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
31 const root = top.stdout.trim()
32 return top.exitCode === 0 && root !== '' ? root : cwd
33 } catch {
34 // No git here, or the command did not run: paths are shown against the session's directory.
35 return cwd
36 }
37}
38
39/**
40 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
41 * transcript line, as before. The model's note is another channel and does not change here.
42 */
43async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
44 try {
45 const taken = await $.sidebar.set({
46 consumer: 'edit-loop',
47 key: sectionKey(key),
48 title,
49 lines,
50 until: 'stream',
51 })
52 if (taken) return
53 } catch {
54 // The sidebar mod is not installed.
55 }
56 $.ui.log(line)
57}
58
59/**
60 * Counts a finished edit. The third edit of one file warns the person in yellow, the fifth turns the
61 * finding red and is the one the model reads; every other edit passes without a word.
62 */
63async function afterEdit($: EngineInterface, state: State, agentId: string | undefined, path: string, r: ToolCallResult): Promise<ToolCallResult> {
64 if (r.deny !== undefined || r.isError === true) return r
65 await readSettings($, state)
66 if (!state.enabled) return r
67 const count = countEdit(state.counts, agentId, path)
68 if (count !== WARN_THRESHOLD && count !== THRESHOLD) return r
69 const shown = shownPath(path, state.root ?? (await $.session.cwd()))
70 if (count === WARN_THRESHOLD) {
71 await toPerson($, shown, 'edits piling up', sidebarLines(shown, count), logText(shown, count))
72 return r
73 }
74 // The note goes to the model, the line to the person: neither reads the other's channel.
75 await toPerson($, shown, 'edit loop', sidebarLines(shown), logText(shown))
76 return { ...r, context: [...(r.context ?? []), noteText(shown)] }
77}
78
79async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
80 const word = args.trim()
81 if (word === 'on' || word === 'off') {
82 await $.store.set(ENABLED_KEY, word === 'on')
83 state.enabled = word === 'on'
84 return word === 'on' ? 'on: the fifth edit of one file in a turn gets a note' : 'off: edits are not counted'
85 }
86 if (word !== '') return USAGE
87 await readSettings($, state)
88 return state.enabled ? 'on' : 'off'
89}
90
91export const register: Register = on => {
92 const state: State = { counts: new Map(), enabled: true }
93
94 on('session.start', async ($, e, next) => {
95 const r = await next(e)
96 await $.command.register({ name: 'edit-loop', description: 'A note at the fifth edit of one file in a turn: status, on, off (edit-loop)', argumentHint: '[on | off]' })
97 await readSettings($, state)
98 state.root = await shownRootOf($)
99 return r
100 })
101
102 // The engine prints the plugin name in front of command text, so the texts do not repeat it.
103 on('command.run', { command: 'edit-loop' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
104
105 on('turn.start', async (_, e, next) => {
106 state.counts.clear()
107 return next(e)
108 })
109
110 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.agentId, e.file_path, await next(e)))
111 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.agentId, e.file_path, await next(e)))
112 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => afterEdit($, state, e.agentId, e.notebook_path, await next(e)))
113}
114hooks/loop.ts 58 lines1/** How many times each loop edited each file in the running turn, and the note at the threshold. */
2
3/** The edit that reaches this count gets the note the model reads. */
4export const THRESHOLD = 5
5
6/** The edit that reaches this count gets the first warning, to the person only. */
7export const WARN_THRESHOLD = 3
8
9/** Edits per loop and file in the running turn; the main loop is `main`, a subagent its id. */
10export type Counts = Map<string, number>
11
12const key = (agentId: string | undefined, path: string): string => `${agentId ?? 'main'}\0${path}`
13
14/** Counts one edit and answers how many times this loop has edited that file in this turn. */
15export function countEdit(counts: Counts, agentId: string | undefined, path: string): number {
16 const k = key(agentId, path)
17 const n = (counts.get(k) ?? 0) + 1
18 counts.set(k, n)
19 return n
20}
21
22/** `path` shown relative to the session directory when it is inside it. */
23export function shownPath(path: string, cwd: string): string {
24 const base = `${cwd.replace(/\/+$/, '')}/`
25 return path.startsWith(base) ? path.slice(base.length) : path
26}
27
28export function noteText(path: string): string {
29 return `edit-loop: this turn edited ${path} ${THRESHOLD} times. Stop editing it, re-read the code path and state the root cause before the next edit.`
30}
31
32/** The transcript line: the finding alone, without the instruction the model reads. The engine adds the mod name. */
33export function logText(path: string, count = THRESHOLD): string {
34 return sidebarLines(path, count).map(l => l.text).join('\n')
35}
36
37/** How the sidebar colours a line or a part of one. */
38type Tone = 'ok' | 'warn' | 'error' | 'dim'
39export type Part = { text: string; kind?: Tone }
40/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
41export type Line = { text: string; kind?: Tone; parts?: Part[] }
42
43const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
44
45/** A line made of parts, its `text` their texts joined. */
46const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
47
48/** `logText` as a sidebar line: the ordinal yellow at the warning and red at the note, the path default, the rest faint. */
49export function sidebarLines(path: string, count = THRESHOLD): Line[] {
50 const ordinal = `${count}${count === WARN_THRESHOLD ? 'rd' : 'th'}`
51 return [partsLine([part(ordinal, count === WARN_THRESHOLD ? 'warn' : 'error'), part(` edit of ${path}`, undefined), part(' in this turn', 'dim')])]
52}
53
54/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
55export function sectionKey(text: string): string {
56 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
57}
58