Tells Claude when other Claude chats are working in the same repo, and which files they edit, so it can message them before work collides.

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.
From a shell:
claude plugin marketplace add dante32683/claude-code-mods
claude plugin install agent-crabs@dante32683-mods
Swap agent-crabs for any mod below. In the desktop app, click + next to the prompt box, then Plugins, add the marketplace dante32683/claude-code-mods, and pick the mods you want.
Mods load when a chat starts, so open a new chat after installing.
A little Clawd above the prompt for each subagent that's running, with what it's doing right now. The crab acts it out: reading a book, walking around with a magnifying glass while it searches, writing on paper while it edits, jogging while a command runs, holding a globe while it's on the web, and thinking in between. It throws confetti when the subagent finishes and sits under a rain cloud if it fails. /crabs-demo plays every animation.
The animated crabs show in the desktop app; the terminal gets a small text crab.
A pane with your plan's 5-hour and weekly usage, how fast you're using it, how much per hour or day you can spend and still make the reset, and what's filling the context window. While the pane is closed, the usage bars sit above the prompt. /meter shows or hides the pane. It saves the last reading to statusline-limits.json in your Claude folder.
Better compaction. Before each compaction it saves the whole conversation to a searchable file in ~/.claude/compact-history/ and tells Claude where it is, so details the summary drops can still be found. Your own messages are kept word for word, the summary is written as a handoff, instruction files are re-read afterward, and when a turn ends close to the auto-compact limit it compacts right away instead of mid-task. History older than 30 days is deleted. Options: turnEndPercent and verbatimUserTokens.
Tells Claude when other Claude chats are working in the same repo (same checkout or another worktree) and which files they've edited, so it can coordinate before work collides. Windows only for now.
/aside <task> hands a side task to a background worker while your chat keeps going. Unlike /btw, the worker has tools. It sees the last few messages for context, and its answer opens in a pane where you can send it to the chat or dismiss it. Haiku by default; /aside opus: <task> uses Opus.
Windows Git Bash only. It fixes a few ways Claude's Bash tool breaks there: very long commands get cut off or mangled, rg patterns starting with / get turned into Windows paths, sed -i doesn't say when nothing changed, and rm "${DIR:?}"/... keeps asking for approval.
agent-crabs takes its idea from hamzafer's agent-radar (MIT), and Clawd's shape from Codrops' article on reverse-engineering Claude's mascot animations. Clawd is Anthropic's mascot; this is a fan project, not affiliated with Anthropic. compact-plus's summary style is modeled on OpenAI's Codex CLI.
MIT
hooks/register.ts 161 lines1// repo-peers: other Claude chats in the same repo.
2//
3// Each session keeps a small heartbeat file of its own in %TEMP%/claude-peers
4// (one writer per file, so no races): its repo, checkout, branch, title, first
5// prompt, files it edited, and whether a turn is running. At each prompt the
6// session reads its peers' files, and when the set of peers working in this
7// repo changes, or a peer has edited a file this chat edited too, Claude gets
8// a hidden note naming them and suggesting it message a peer whose work could
9// collide. Windows only: the folder comes from %TEMP% via cmd.
10import type { Register, EngineInterface } from 'claude-code'
11
12export const ACTIVE_MS = 30 * 60_000 // a peer idle longer than this is left out
13const MAX_FILES = 15
14const BEAT_MS = 2 * 60_000
15
16export type Beat = {
17 id: string
18 repo: string // git common dir: the same for every worktree of one repo
19 checkout: string
20 branch: string
21 title: string
22 firstPrompt: string
23 files: string[]
24 working: boolean
25 updated: number
26 ended: boolean
27}
28
29type Me = { beat: Beat; told: string }
30let me: Me | null = null
31let dir: Promise<string | undefined> | undefined
32
33function peersDir($: EngineInterface): Promise<string | undefined> {
34 dir ??= $.process
35 .run(['cmd', '/d', '/c', 'echo %TEMP%'])
36 .then(r => {
37 const d = r.stdout.trim().replaceAll('\\', '/')
38 return r.exitCode === 0 && /^[A-Za-z]:\//.test(d) ? `${d}/claude-peers` : undefined
39 })
40 .catch(() => undefined)
41 return dir
42}
43
44const norm = (p: string) => p.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
45
46async function gitInfo($: EngineInterface, cwd: string) {
47 const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel', '--abbrev-ref', 'HEAD'], { timeoutMs: 10_000 })
48 if (r.exitCode !== 0) return null
49 const [repo, checkout, branch] = r.stdout.trim().split(/\r?\n/)
50 return repo && checkout ? { repo: norm(repo), checkout: norm(checkout), branch: branch ?? '' } : null
51}
52
53async function save($: EngineInterface): Promise<void> {
54 const d = await peersDir($)
55 if (d && me) await $.fs.write(`${d}/${me.beat.id}.json`, JSON.stringify(me.beat))
56}
57
58export async function readPeers($: EngineInterface, self: Beat, now: number): Promise<Beat[]> {
59 const d = await peersDir($)
60 if (!d) return []
61 let entries
62 try {
63 entries = await $.fs.list(d)
64 } catch {
65 return []
66 }
67 const out: Beat[] = []
68 for (const e of entries) {
69 if (e.kind !== 'file' || !e.name.endsWith('.json') || e.name === `${self.id}.json`) continue
70 if (typeof e.mtimeMs === 'number' && now - e.mtimeMs > ACTIVE_MS) continue
71 try {
72 const raw = await $.fs.read(`${d}/${e.name}`)
73 const b = JSON.parse(typeof raw === 'string' ? raw : '') as Beat
74 if (b.ended || b.repo !== self.repo || now - b.updated > ACTIVE_MS) continue
75 out.push(b)
76 } catch {}
77 }
78 return out.sort((a, b) => a.id.localeCompare(b.id))
79}
80
81const ago = (ms: number) => (ms < 90_000 ? 'just now' : `${Math.round(ms / 60_000)} min ago`)
82
83export function peerNote(self: Beat, peers: Beat[], now: number): { text: string | null; sig: string } {
84 const mine = new Set(self.files)
85 const rows = peers.map(p => ({ p, shared: p.files.filter(f => mine.has(f)) }))
86 const sig = rows.map(r => `${r.p.id}:${r.p.checkout === self.checkout ? 'same' : 'other'}:${r.shared.join(',')}`).join('|')
87 if (!rows.length) return { text: null, sig }
88 const lines = ['repo-peers: other Claude chats are working in this repo:']
89 for (const { p, shared } of rows) {
90 const where = p.checkout === self.checkout ? `same checkout (${p.branch})` : `worktree ${p.checkout} (${p.branch})`
91 const name = p.title ? `"${p.title}"` : `chat starting "${p.firstPrompt}"`
92 const state = p.working ? `working now (updated ${ago(now - p.updated)})` : `idle since ${ago(now - p.updated)}`
93 lines.push(`- ${name}: ${where}, ${state}`)
94 if (shared.length) lines.push(` edited the same files as this chat: ${shared.join(', ')}`)
95 else if (p.files.length) lines.push(` edited: ${p.files.slice(-6).join(', ')}`)
96 }
97 lines.push('If your work could collide with one (same files, branch, commits or a running gate), find it with list_sessions and send it a short send_message on what you are changing and how to stay clear.')
98 return { text: lines.join('\n'), sig }
99}
100
101const rel = (path: string) => (me ? norm(path).replace(me.beat.checkout + '/', '') : norm(path))
102
103function editedPath(tool: string, input: unknown): string | null {
104 if (!/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool) || !input || typeof input !== 'object') return null
105 const i = input as Record<string, unknown>
106 const p = i.file_path ?? i.notebook_path
107 return typeof p === 'string' ? p : null
108}
109
110export const register: Register = on => {
111 on('classic.UserPromptSubmit', async ($, e, next) => {
112 const r = await next(e)
113 if (e.agent_id) return r
114 const now = await $.clock.now()
115 if (!me || me.beat.id !== e.session_id) {
116 const g = await gitInfo($, e.cwd)
117 if (!g) return r
118 const beat: Beat = { id: e.session_id, ...g, title: '', firstPrompt: e.prompt.replace(/\s+/g, ' ').slice(0, 80), files: [], working: true, updated: now, ended: false }
119 me = { beat, told: '' }
120 }
121 me.beat = { ...me.beat, title: e.session_title ?? me.beat.title, working: true, updated: now }
122 await save($)
123 const { text, sig } = peerNote(me.beat, await readPeers($, me.beat, now), now)
124 if (sig === me.told) return r
125 me.told = sig
126 return text ? { ...r, additionalContext: [...(r.additionalContext ?? []), text] } : r
127 }).catch(($, e, next) => next(e))
128
129 // Every tool call keeps the heartbeat fresh (at most every 2 minutes), so a
130 // long turn of Bash work doesn't read as idle; an edit is saved at once.
131 on('classic.PostToolUse', async ($, e, next) => {
132 const r = await next(e)
133 if (!me) return r
134 const now = await $.clock.now()
135 const path = editedPath(e.tool_name, e.tool_input)
136 if (!path && now - me.beat.updated < BEAT_MS) return r
137 const files = path ? [...me.beat.files.filter(f => f !== rel(path)), rel(path)].slice(-MAX_FILES) : me.beat.files
138 me.beat = { ...me.beat, files, updated: now }
139 await save($)
140 return r
141 }).catch(($, e, next) => next(e))
142
143 on('classic.Stop', async ($, e, next) => {
144 const r = await next(e)
145 if (me) {
146 me.beat = { ...me.beat, working: false, updated: await $.clock.now() }
147 await save($)
148 }
149 return r
150 }).catch(($, e, next) => next(e))
151
152 on('classic.SessionEnd', async ($, e, next) => {
153 const r = await next(e)
154 if (me) {
155 me.beat = { ...me.beat, working: false, ended: true }
156 await save($)
157 }
158 return r
159 }).catch(($, e, next) => next(e))
160}
161