Asks before Claude edits a file that another tool (Gemini, an editor, another agent) changed in the last few minutes, through Edit/Write, a shell command that…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.ts 183 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShellWindow } from '../types'
5
6// Before Edit/Write/NotebookEdit, a shell command that writes, or an MCP tool that writes a file, asks when
7// the file changed in the last few minutes by something other than this chat: Gemini or another agent on a
8// shared folder, an editor, a sync. Built-in Edit already refuses a file that changed since Claude read it;
9// this also covers a file Claude never read, and asks before Claude starts building on a half-finished
10// outside edit. A Cancel sticks to that outside version until the person says Edit anyway or it ages out.
11// $.fs.stat, $.clock and $.ui.ask only: no network, processes or model calls.
12
13const seen = atom({ plugin: 'shared-edit-guard', key: 'seen' } as const, {})
14const refused = atom({ plugin: 'shared-edit-guard', key: 'refused' } as const, {})
15const shells = atom({ plugin: 'shared-edit-guard', key: 'shells' } as const, [])
16
17const SLACK_MS = 2000
18const SEEN_MAX = 500
19const COMMAND_MAX = 2000
20const NAMED_MAX = 20
21const ASK_OPTIONS = { options: ['Cancel', 'Edit anyway'], header: 'Shared file' } as const
22
23// Windows paths (drive letter or backslashes) compare case-insensitively.
24const isWin = (p: string) => /^[A-Za-z]:|\\/.test(p)
25export const keyOf = (p: string) => (isWin(p) ? p.replace(/\\/g, '/').toLowerCase() : p)
26
27async function mtime($: EngineInterface, path: string): Promise<number | undefined> {
28 try {
29 const s = await $.fs.stat(path)
30 return s.kind === 'file' ? s.mtimeMs : undefined
31 } catch {
32 return undefined // no such file yet
33 }
34}
35
36// Versions older than the window can never cause a prompt, so they are dropped; the rest are capped.
37export const remembered = (all: Record<string, number>, key: string, m: number, cutoff: number) =>
38 Object.fromEntries(
39 Object.entries({ ...all, [key]: m })
40 .filter(([, t]) => t > cutoff)
41 .sort((a, b) => b[1] - a[1])
42 .slice(0, SEEN_MAX),
43 )
44
45async function remember($: EngineInterface, path: string, windowMs: number) {
46 const m = await mtime($, path)
47 if (m === undefined) return
48 const cutoff = (await $.clock.now()) - windowMs
49 await update($, seen, all => remembered(all, keyOf(path), m, cutoff))
50}
51
52// Does the command name this file (its basename as a whole word, so also any path ending in it)?
53const names = (command: string, path: string) => {
54 const fold = (s: string) => (isWin(path) ? s.toLowerCase() : s)
55 const base = fold(path.split(/[\\/]/).pop() ?? '')
56 const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57 return base !== '' && new RegExp(`(^|[^\\w.-])${esc}($|[^\\w.-])`).test(fold(command))
58}
59
60// Does the command look like it changes files: a redirect, PowerShell's writers, an in-place editor, a copy,
61// move or delete, a formatter or fixer, a one-liner that opens a file for writing?
62const WRITES = new RegExp(
63 [
64 String.raw`(?:^|[^\d&>=-])>{1,2}(?!\s*(?:&|/dev/null|\$null\b|nul\b))`,
65 String.raw`\b(?:Set-Content|Add-Content|Out-File|Clear-Content|Tee-Object|New-Item|Remove-Item|Move-Item|Copy-Item|Rename-Item|Export-Csv|Export-Clixml|WriteAll(?:Text|Lines|Bytes)|AppendAll(?:Text|Lines))\b`,
66 String.raw`\b(?:sed|perl)\b[^|;&]*\s(?:-\w*i\b|--in-place)`,
67 String.raw`\b(?:tee|cp|mv|rm|del|copy|move|ren|truncate|touch|patch|dd)\b`,
68 String.raw`\bgit\s+(?:checkout|restore|apply|stash|reset|mv|rm)\b`,
69 String.raw`\b(?:black|isort|autopep8|yapf|gofmt|rustfmt|clang-format)\b|\bdotnet\s+format\b|\bruff\s+format\b`,
70 String.raw`\b(?:prettier|eslint|ruff)\b[^|;&]*\s--(?:write|fix)\b`,
71 String.raw`\bopen\([^)]*['"][wax]\+?['"]|\.write(?:_text|_bytes)?\(|\b(?:writeFileSync|appendFileSync)\b`,
72 ].join('|'),
73 'i',
74)
75export const writes = (command: string) => WRITES.test(command)
76
77// The files a command names: quoted paths whole (they may hold spaces), and path-looking words with an extension.
78export const named = (command: string) => {
79 const quoted: string[] = []
80 const rest = command.replace(/"([^"]+)"|'([^']+)'/g, (all: string, a?: string, b?: string) => {
81 const s = a ?? b ?? ''
82 if (!/^(?:[A-Za-z]:)?[^*?<>|:]*\.\w{1,10}$/.test(s)) return all
83 quoted.push(s)
84 return ' '
85 })
86 const words = rest.match(/(?:[A-Za-z]:)?(?:[\w.~-]*[\\/])*[\w.-]*\w\.\w{1,10}(?![\w\\/])/g) ?? []
87 return [...new Set([...quoted, ...words])].filter(p => !/^\w+:\/\//.test(p)).slice(0, NAMED_MAX)
88}
89
90// Parallel agents keep some shell running almost all the time, so a window only covers changes its own
91// command could have made: to files it names, and only when it looks like it writes (a 9-minute run that
92// reads a config does not make an outside edit to that config its own).
93export const byOwnShell = (m: number, path: string, windows: readonly ShellWindow[]) =>
94 windows.some(w => m >= w.from - SLACK_MS && m <= w.to + SLACK_MS && writes(w.command ?? '') && names(w.command ?? '', path))
95
96const ago = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`)
97const short = (p: string) => p.replace(/\\/g, '/').split('/').slice(-2).join('/')
98
99// Asks when the file changed outside this chat within the window; answers the deny text, or undefined to go
100// on. A Cancel is kept for that outside version, so reading the file does not turn the next change into a
101// silent one: it asks again until the person says Edit anyway or the change ages out of the window.
102async function check($: EngineInterface, path: string, windowMs: number): Promise<string | undefined> {
103 const m = await mtime($, path)
104 if (m === undefined) return undefined
105 const now = await $.clock.now()
106 const key = keyOf(path)
107 if (now - m >= windowMs) return undefined
108 const isRefused = (await read($, refused))[key] === m
109 if (!isRefused && ((await read($, seen))[key] === m || byOwnShell(m, path, await read($, shells)))) return undefined
110 let answer: string
111 try {
112 answer = await $.ui.ask(`${short(path)} was changed outside this chat ${ago(now - m)} ago (Gemini or another tool?). Edit it anyway?`, ASK_OPTIONS)
113 } catch {
114 answer = ''
115 }
116 if (answer === 'Edit anyway') {
117 await update($, refused, all => Object.fromEntries(Object.entries(all).filter(([k]) => k !== key)))
118 return undefined
119 }
120 await update($, refused, all => remembered(all, key, m, now - windowMs))
121 return `shared-edit-guard: ${path} was changed outside this chat ${ago(now - m)} ago and the user did not approve changing it. Do not retry, and do not change it another way (a shell command such as Set-Content, Out-File, sed -i or a redirect, a script, or another tool): tell the user what you meant to change and wait for them. Reading the file is fine; the next change asks again.`
122}
123
124// MCP tools whose name says they write, and the path-looking arguments they write to.
125const MCP_WRITES = /write|edit|create|update|move|rename|delete|remove|append|patch|save|replace|insert/i
126export const mcpPaths = (tool: string, input: Record<string, unknown>) =>
127 tool.startsWith('mcp__') && MCP_WRITES.test(tool.split('__').slice(2).join('__'))
128 ? Object.entries(input)
129 .filter(([k, v]) => /path|file|dest|target|source/i.test(k) && typeof v === 'string' && /[\\/]|\.\w{1,10}$/.test(v))
130 .map(([, v]) => v as string)
131 : []
132
133export const register: Register = (on, options) => {
134 const windowMs = Math.max(1, Number(options.windowMinutes ?? 10)) * 60_000
135
136 // Gate: judge before next, so the .catch below can refuse on any failure.
137 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
138 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
139 const deny = await check($, path, windowMs)
140 if (deny) return { deny }
141 const ran = await next(e)
142 if (!ran.deny && !ran.isError) await remember($, path, windowMs)
143 return ran
144 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'shared-edit-guard: the shared-file check failed, so the edit was blocked.' }))
145
146 // Ledger: the version Claude read is one it knows.
147 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
148 const ran = await next(e)
149 if (!ran.deny && !ran.isError) await remember($, e.file_path, windowMs).catch(() => undefined)
150 return ran
151 }).catch(($, e, next) => next(e))
152
153 // A shell command that looks like it writes asks first about the files it names. Changes made while one
154 // ran, to files it names, are its own.
155 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
156 const command = String(e.command ?? '').slice(0, COMMAND_MAX)
157 if (writes(command)) {
158 for (const path of named(command)) {
159 const deny = await check($, path, windowMs)
160 if (deny) return { deny }
161 }
162 }
163 const from = await $.clock.now()
164 const ran = await next(e)
165 const to = await $.clock.now()
166 await update($, shells, list => [...list, { from, to, command }].filter(w => to - w.to < 24 * 60 * 60_000).slice(-200)).catch(() => undefined)
167 return ran
168 }).catch(($, e, next) => next(e))
169
170 // MCP tools that write a file (a filesystem server's write_file, edit_file, move_file) ask the same way.
171 on('tool.call', async ($, e, next) => {
172 const paths = mcpPaths(String(e.tool), e as Record<string, unknown>)
173 if (paths.length === 0) return next(e)
174 for (const path of paths) {
175 const deny = await check($, path, windowMs)
176 if (deny) return { deny }
177 }
178 const ran = await next(e)
179 if (!ran.deny && !ran.isError) for (const path of paths) await remember($, path, windowMs).catch(() => undefined)
180 return ran
181 }).catch(($, e, next) => next(e))
182}
183types/index.d.ts 16 lines1/** command: the shell command text, truncated; only files it names count as its own changes, and only when it looks like it writes */
2export type ShellWindow = { from: number; to: number; command: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'shared-edit-guard': {
7 /** normalized path -> mtimeMs of the version this chat last read or wrote */
8 seen: Record<string, number>
9 /** normalized path -> mtimeMs of an outside version the user answered Cancel for; asked again until Edit anyway or it ages out */
10 refused: Record<string, number>
11 /** when this chat's own shell commands ran and what they said, so changes to files they name count as its own */
12 shells: ShellWindow[]
13 }
14 }
15}
16