SLOPSHOPPER

repo-peers

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.

newprocess
v0.1.0MITupdated 2026-10-21dante32683/claude-code-mods/repo-peers
A shopper browsing a rack in a slop shop
README

claude-code-mods

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.

agent-crabs

Install

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.

Mods

agent-crabs

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.

usage-context

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.

compact-plus

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.

repo-peers

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

/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.

bash-fixes

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.

Credits

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.

License

MIT

Source 1 files
hooks/register.ts 161 lines
1// 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