Warns when another Claude session on the same repo already edited the file being changed, or claimed the same package version.

What it does: warns when two Claude sessions work on the same repo and touch the same file, or both claim the same version number. Catching that early avoids the merge conflicts and renumbering it otherwise causes.
Where you see it:
⚠ src/09-render.js was also edited 25m ago by another session (release/v0.10.1 · spacex · "Tesla Roadster…").package.json gets a version another session (on another branch) already uses./collisions: other sessions active on this repo in the last 24h, their branch, worktree, version, and the files you both edited.How it works: each session records the files it edits (Edit/Write/NotebookEdit) in its own file, keyed by repo. Worktrees count as the same repo, and paths are compared repo-relative. Before an edit, it reads the other sessions' files. Edits older than 12 hours are ignored.
Cost: none. No model calls.
Data saved: ~/.claude/mods-data/collision-guard/<repo>/<session>.json.
Notes: it only knows about sessions that also run this mod. It warns, it never blocks.
hooks/register.tsx 177 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3// Each session writes what it edits to its own file under
4// mods-data/collision-guard/<repo>/<session>.json and reads the others', so
5// parallel sessions (and their worktrees) see each other without sharing a file.
6
7type SessionRecord = {
8 session: string
9 branch: string
10 worktree: string
11 topic: string
12 updatedAt: number
13 version: string | null
14 files: { [rel: string]: number }
15}
16
17type Ctx = { dir: string; root: string; top: string; branch: string; worktree: string; session: string; checkedAt: number }
18
19const FILE_WINDOW_MS = 12 * 3600_000
20const SESSION_WINDOW_MS = 24 * 3600_000
21
22const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
23
24const oneLine = (text: string, max: number) => {
25 const flat = text.replace(/\s+/g, ' ').trim()
26 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
27}
28
29const ago = (ms: number) => {
30 const m = Math.max(0, Math.round(ms / 60000))
31 if (m < 60) return `${m}m ago`
32 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m ago`
33}
34
35const who = (r: SessionRecord) => `${r.branch || 'detached'}${r.worktree ? ` · ${r.worktree}` : ''}${r.topic ? ` · "${oneLine(r.topic, 40)}"` : ''}`
36
37let ctx: Ctx | null = null
38let own: SessionRecord | null = null
39let topic = ''
40const warned = new Set<string>()
41
42const context = async ($: EngineInterface): Promise<Ctx | null> => {
43 const now = await $.clock.now()
44 if (ctx && now - ctx.checkedAt < 60_000) return ctx
45 const repo = await $.session.repo()
46 if (!repo) return null
47 const git = async (args: string[]) => {
48 const run = await $.process.run(['git', ...args]).catch(() => null)
49 return run && run.exitCode === 0 ? run.stdout.trim() : ''
50 }
51 const top = norm((await git(['rev-parse', '--show-toplevel'])) || repo.root)
52 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
53 const key = norm(repo.root).replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
54 ctx = {
55 dir: `${home.replace(/\\/g, '/')}/.claude/mods-data/collision-guard/${key}`,
56 root: norm(repo.root),
57 top,
58 branch: await git(['branch', '--show-current']),
59 worktree: top === norm(repo.root) ? '' : top.split('/').pop() ?? '',
60 session: await $.session.id(),
61 checkedAt: now,
62 }
63 return ctx
64}
65
66// Repo-relative path, the same for the main checkout and every worktree.
67const relOf = (c: Ctx, path: string) => {
68 const p = norm(path)
69 const wt = /\/\.claude\/worktrees\/[^/]+\/(.+)$/.exec(p)
70 if (wt) return wt[1]!
71 for (const base of [c.top, c.root]) if (p.startsWith(`${base}/`)) return p.slice(base.length + 1)
72 return null
73}
74
75const others = async ($: EngineInterface, c: Ctx, now: number) => {
76 if (!(await $.fs.exists(c.dir))) return []
77 const out: SessionRecord[] = []
78 for (const f of await $.fs.list(c.dir)) {
79 if (!f.name.endsWith('.json') || f.name === `${c.session}.json`) continue
80 try {
81 const r = JSON.parse(await $.fs.read(`${c.dir}/${f.name}`)) as SessionRecord
82 if (now - r.updatedAt < SESSION_WINDOW_MS) out.push(r)
83 } catch {
84 // skip a file mid-write
85 }
86 }
87 return out
88}
89
90const save = async ($: EngineInterface, c: Ctx, change: (r: SessionRecord) => void) => {
91 const file = `${c.dir}/${c.session}.json`
92 if (!own) {
93 own = (await $.fs.exists(file))
94 ? (JSON.parse(await $.fs.read(file)) as SessionRecord)
95 : { session: c.session, branch: c.branch, worktree: c.worktree, topic, updatedAt: 0, version: null, files: {} }
96 }
97 change(own)
98 Object.assign(own, { branch: c.branch, worktree: c.worktree, topic: own.topic || topic, updatedAt: await $.clock.now() })
99 await $.fs.write(file, JSON.stringify(own))
100}
101
102export const register: Register = on => {
103 on('session.start', async ($, e, next) => {
104 await $.command.register({ name: 'collisions', description: 'Other Claude sessions editing this repo, and the files you both touched' })
105 return next(e)
106 })
107
108 on('turn.start', ($, e, next) => {
109 if (!topic && e.text.trim() !== '') topic = oneLine(e.text, 60)
110 return next(e)
111 })
112
113 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
114 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : ''
115 const c = await context($).catch(() => null)
116 const rel = c && path ? relOf(c, path) : null
117 if (!c || !rel) return next(e)
118
119 const now = await $.clock.now()
120 const peers = await others($, c, now).catch(() => [] as SessionRecord[])
121 const ran: ToolCallResult = await next(e)
122 if (ran.deny !== undefined || ran.isError === true) return ran
123
124 const notes: string[] = []
125 const hits = peers.filter(r => r.files[rel] !== undefined && now - r.files[rel]! < FILE_WINDOW_MS)
126 if (hits.length > 0 && !warned.has(rel)) {
127 warned.add(rel)
128 const first = hits.sort((a, b) => b.files[rel]! - a.files[rel]!)[0]!
129 $.ui.toast(`⚠ ${rel} was also edited ${ago(now - first.files[rel]!)} by another session (${who(first)})`, { timeoutMs: 10_000 })
130 notes.push(
131 `[collision-guard] Another Claude session edited ${rel} recently: ${hits.map(r => `${who(r)}, ${ago(now - r.files[rel]!)}`).join('; ')}. ` +
132 'Expect a merge conflict in this file; keep the change focused and mention the overlap to the person when you report.',
133 )
134 }
135
136 let version: string | null = null
137 if (rel === 'package.json') {
138 try {
139 version = (JSON.parse(await $.fs.read(path)) as { version?: string }).version ?? null
140 } catch {
141 version = null
142 }
143 const clash = version ? peers.find(r => r.version === version && r.branch !== c.branch) : undefined
144 if (clash && version && !warned.has(`v:${version}`)) {
145 warned.add(`v:${version}`)
146 $.ui.toast(`⚠ v${version} is already used by another session (${who(clash)})`, { timeoutMs: 10_000 })
147 notes.push(`[collision-guard] Version ${version} is already claimed by another session (${who(clash)}). Pick the next free version unless the person says otherwise.`)
148 }
149 }
150
151 void save($, c, r => {
152 r.files[rel] = now
153 if (version) r.version = version
154 }).catch(() => undefined)
155
156 return notes.length > 0 ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
157 })
158
159 on('command.run', { command: 'collisions' }, async $ => {
160 const c = await context($)
161 if (!c) return { text: 'Not in a git repository.' }
162 const now = await $.clock.now()
163 const peers = await others($, c, now)
164 const mine = new Set(Object.keys(own?.files ?? {}))
165 if (peers.length === 0) return { text: 'No other Claude sessions have edited this repo in the last 24 hours.' }
166 const lines: string[] = []
167 for (const r of peers.sort((a, b) => b.updatedAt - a.updatedAt)) {
168 const files = Object.entries(r.files).sort((a, b) => b[1] - a[1])
169 const shared = files.filter(([f]) => mine.has(f)).map(([f]) => f)
170 lines.push(`• ${who(r)} — active ${ago(now - r.updatedAt)}${r.version ? ` · v${r.version}` : ''} · ${files.length} files`)
171 if (shared.length > 0) lines.push(` both edited: ${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ` +${shared.length - 8}` : ''}`)
172 else lines.push(` recent: ${files.slice(0, 5).map(([f]) => f).join(', ')}`)
173 }
174 return { text: `Other sessions on this repo (${c.branch || 'detached'} here):\n${lines.join('\n')}` }
175 })
176}
177