Huddle: when two or more Claude Code sessions work in the same repo, see it before they collide, and get them to split the work with one button.

Two Claude Code sessions in the same repo, about to edit the same file? Huddle sees it first, and gets them to split the work with one button.
You run several Claude Code sessions at once. One is fixing auth, another is adding a feature, a third is "just cleaning up". They don't know about each other:
package.json or the lockfile, or both write a migration, and the merge is a mess.git stash or checkout under the other's feet.Huddle watches for that, tells you how bad it is, and when you press Coordinate the agents agree on who owns what before they collide.
A band above the prompt, colored by how serious the overlap is:
■ Clash in acme-api · both edited 2 files (src/auth.ts, src/session.ts) · «SSO login» · feature/sso
▲ Another agent in acme-web · same working copy (no worktree of its own) · «Dependency bumps» · main
Coordinating is recommended. [ Coordinate ] [ Snooze 30 min ]
Each other session is shown by the name you see in your sidebar (or, until it has one, by the start of what it was asked), next to the repo the alert is about. In the Claude desktop app, click that name to jump to the session.
Press Coordinate (or run /huddle) and:
agree tool, which reaches everyone. It gives you the summary.Once a huddle is going, the band changes with it:
✓ Huddle with 6 sessions · led by «Billing refactor» · 5/6 confirmed. A session confirms the first time it sends a message after joining. If a member edits a file the agreement gave to another member, a second soft line says so: ▲ «Auth» edited src/auth.ts (owner «SSO login»).Owner warnings reach the agent while it works, not at the end of its turn: when a session edits (with a file tool or from the shell) a file another live member owns, that tool's result carries a note ("this file belongs to «B» in the agreement") and you get a toast, once per file. A huddle message that arrives while an agent is mid-turn is also copied into its next tool result, so a long run sees it within the turn.
/plugin install huddle --marketplace Br4inergy/claude-huddle
Install it in every Claude Code where you run sessions. Huddle sees the sessions that have it. (A session still on 0.2.x shows up for 0.3.0 sessions, but doesn't see them.)
Judged per repo, over every repo two sessions share. A session works in its home repo (where it started) and in any other repo it edits a file in or enters from the shell (cd, git -C, pushd), for the next 4 hours. Each peer raises one alert, for the strongest repo it shares with you.
| Level | When | What you see |
|---|---|---|
| 1 · info | Another session in the same repo, in another worktree or clone, on different files | Grey line, status bar count |
| 2 · attention | Another session in the same working copy (shared index, stash, installs) | Yellow line |
| 3 · clash | Both sessions edited the same file in the last 4 hours, or both touched single-owner files (lockfile, package.json, migrations, schema, generated code) | Red line, a toast, "Coordinating is recommended" |
So a session that starts in one repo and goes on to edit ~/code/api clashes with the session that lives there. The same repo is recognized across clones and worktrees by its origin remote (or, with no remote, its git directory).
Idle sessions don't alert. A session nobody has typed to and whose agent has made no tool call in the last 2 hours is left out of the band and out of the huddle's count, unless its files clash with yours.
Shell edits count. Around every Bash command Huddle compares git status (plus the size and modification time of up to 300 dirty files) in your home repo, the repos you entered recently and the directories the command names, at most 3 repos, and attributes what changed to this session. Entering a repo (cd ~/code/api && git push) is enough to show up in it. Files that merely leave the status (a commit, a revert) are not edits.
send tool or SendMessage: their task, the files they edited and the files they plan to touch.package.json, migrations, schema and generated files get a single owner.checkout, stash, reset or installs dependencies without telling the other, and each commits only its own files with a pathspec (git commit -- <files>).agree tool, once: one owner per disputed file and a short summary, sent to every member.peers: the other live sessions that share a repo with this one, with repo, level, task, branch and the files in conflict.send: a message to one of them by session id.agree: for the huddle leader only. { summary, owners: [{ repo?, path, sessionId }] } records who owns which file (repo is a repo name or key, the leader's own repo by default) and sends the summary to every member.git status around each command: a change that never shows as dirty (a git pull, a checkout, a file git ignores) isn't seen, and in a working copy shared by two sessions, a file the other one changes while your command runs is attributed to you unless the other session had already recorded it.claude -p run started from a desktop session, the name is shown, not linked.Each session keeps one small JSON file under ~/.claude/huddle/sessions/: its id, the desktop app's id for it, its sidebar title, the first 140 characters of the first prompt you typed, its language, when it was last active, the huddle it is in, and for each repo it works in the name, working-copy path, branch and the repo-relative files it edited in the last 4 hours. A huddle leader's file also holds the members, the owners and the summary. It is refreshed every 15 seconds; each session writes only its own file and reads the others'. A session gone for a day is emptied on the next check. Nothing leaves your machine except the messages Claude Code itself delivers between your own sessions.
English or Spanish, following Claude Code's language setting; with none, the language of the first thing you type (Spanish when it has more Spanish than English markers), then your locale. Messages to other agents go out in the sender's language.
claude plugin validate .
claude plugin test .
hooks/logic.ts holds the pure parts (levels, huddle state, the band, command parsing, messages) and is what the tests cover. hooks/register.tsx wires them into Claude Code.
MIT © Br4inergy
hooks/register.tsx 853 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, Register, ToolCallResult } from 'claude-code'
3
4import type { Alert, BandState, CoordResult } from '../types'
5import { type Lang, type Words, inferLang, pickLang, words } from './i18n'
6import {
7 type Presence,
8 type RepoEntry,
9 type Snapshot,
10 activeRepos,
11 addMarker,
12 compactForMidTurn,
13 assessAll,
14 bandState,
15 diffSnapshots,
16 dirsInCommand,
17 huddleMarker,
18 huddleMembers,
19 isHostId,
20 isLive,
21 leadPrompt,
22 leaderOf,
23 liveHuddle,
24 nameOf,
25 newHuddleId,
26 normalizeRemote,
27 ownerKey,
28 ownerOf,
29 parseAddMarker,
30 parseHuddleMarker,
31 parsePorcelain,
32 peerMessage,
33 reasonOf,
34 repoNameOf,
35 repoRelative,
36 resolvePath,
37 sameJson,
38 sessionLink,
39 shouldRefreshTitle,
40 slashes,
41 stripMarkers,
42 taskFromPrompt,
43 upgradePresence,
44 FILE_WINDOW_MS,
45} from './logic'
46
47const alerts = atom({ plugin: 'huddle', key: 'alerts' } as const, [] as Alert[])
48const band = atom({ plugin: 'huddle', key: 'band' } as const, { kind: 'none' } as BandState)
49const snoozeUntil = atom({ plugin: 'huddle', key: 'snoozeUntil' } as const, 0)
50const lastCoord = atom({ plugin: 'huddle', key: 'lastCoord' } as const, null as CoordResult | null)
51
52const TICK_MS = 15_000
53const SNOOZE_MS = 30 * 60_000
54const STALE_MS = 24 * 60 * 60_000
55const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
56const HUMAN_ORIGINS = new Set<string>(['composer', 'bridge', 'sdk'])
57// The desktop app's own read of a session: its sidebar title. Asked only when it runs without a permission prompt.
58const GET_SESSION = 'mcp__ccd_session_mgmt__get_session'
59const LEVEL_MARK = { 1: '◦', 2: '▲', 3: '■' } as const
60const LEVEL_COLOR = { 1: 'subtle', 2: 'warning', 3: 'error' } as const
61// A shell command is watched in at most this many repos; git gets this long to list a repo's dirty files.
62const SNAPSHOT_REPOS = 3
63const SNAPSHOT_MS = 3000
64const STAT_CAP = 300
65const NEGATIVE_MS = 60_000
66const PENDING_MAX = 5
67const ID_RE = /^[A-Za-z0-9_-]{1,80}$/
68
69// The module's own variables: a reload starts them over and session.start fills them again.
70let me: Presence | null = null
71let dir = ''
72let homeDir = ''
73let w: Words = words('en')
74// What the person's own first prompt read as (Spanish or English), for when no setting says.
75let inferred: Lang | null = null
76// Peer+repo+level triples already toasted, so an alert is raised once per escalation.
77const toasted = new Set<string>()
78// Files already toasted as taken by another member of the huddle.
79const warned = new Set<string>()
80let ticks = 0
81// The desktop app's id from the environment. A `claude -p` started from a desktop session inherits it, so it is
82// taken as this session's own only once the session is drawn somewhere (a headless run never is).
83let envHostId = ''
84// A turn is running between prompt.submit and turn.complete: huddle messages are also copied into its next tool result.
85let busy = false
86let pending: string[] = []
87// The peers as the last tick read them, for names and liveness between ticks.
88let peersSeen: Presence[] = []
89// directory -> its git top level ('' when it is not in a repo, remembered for a minute); top level -> repo identity.
90const topCache = new Map<string, { top: string; at: number }>()
91const identityCache = new Map<string, { repoKey: string; repoName: string }>()
92
93/** Claude Code's `language` setting, then what the person writes, then LC_ALL/LANG; English by default. */
94async function detectLang($: Api): Promise<Lang> {
95 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
96 const locale = (await $.env.get('LC_ALL').catch(() => null)) || (await $.env.get('LANG').catch(() => null)) || null
97 return pickLang(undefined, settings.language, locale, inferred)
98}
99
100async function git($: Api, cwd: string, args: string[]) {
101 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
102 return r.exitCode === 0 ? r.stdout.trim() : ''
103}
104
105/** `git -C <dir> ...`, '' on any failure (no such directory, not a repo, timeout). */
106async function gitC($: Api, where: string, args: string[]) {
107 const r = await $.process.run(['git', '-C', where, ...args], { timeoutMs: 5000 }).catch(() => null)
108 return r && r.exitCode === 0 ? r.stdout.trim() : ''
109}
110
111async function save($: Api) {
112 if (!me) return
113 const now = await $.clock.now()
114 me.lastSeen = now
115 me.repos[me.home]!.seenAt = now
116 // Only what still counts is kept: files edited in the last 4 hours, repos seen in the last 4 hours.
117 for (const [key, repo] of Object.entries(me.repos)) {
118 for (const [file, at] of Object.entries(repo.files)) if (now - at >= FILE_WINDOW_MS) delete repo.files[file]
119 if (key !== me.home && now - (repo.seenAt ?? 0) >= FILE_WINDOW_MS) delete me.repos[key]
120 }
121 await $.fs.write(`${dir}/${me.sessionId}.json`, JSON.stringify(me))
122}
123
124async function readPeers($: Api, now: number): Promise<Presence[]> {
125 const peers: Presence[] = []
126 for (const entry of await $.fs.list(dir)) {
127 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
128 const path = `${dir}/${entry.name}`
129 try {
130 const text = await $.fs.read(path)
131 if (!text) continue
132 const p = upgradePresence(JSON.parse(text))
133 if (!p) continue
134 if (now - p.lastSeen > STALE_MS) {
135 // No delete in $.fs: a session gone for a day is emptied, and an empty file is skipped.
136 await $.fs.write(path, '').catch(() => null)
137 continue
138 }
139 peers.push(p)
140 } catch {
141 // half-written by a peer this instant, or emptied when stale; the next tick reads it
142 }
143 }
144 peersSeen = peers
145 return peers
146}
147
148/** This session's sidebar title from the desktop app, when the app is there and lets it be read without asking. */
149async function refreshTitle($: Api) {
150 if (!me?.hostId) return
151 const tools = await $.tool.list()
152 if (!tools.some(t => t.name === GET_SESSION)) return
153 const input = { tool: GET_SESSION, session_id: 'self' } as never
154 const { decision } = await $.tool.check(input)
155 if (decision !== 'allow') return
156 const { text } = (await $.tool.call(input)) as { text?: string }
157 const title = text ? (JSON.parse(text) as { title?: unknown }).title : undefined
158 if (typeof title === 'string' && title.trim()) me.title = title.trim().slice(0, 120)
159}
160
161/** Opens a session of the desktop app by its own link, through the OS: the band may be drawn where links can't. */
162async function openSession($: Api, hostId: string) {
163 // `start` goes through cmd on Windows: only an id the app could have written reaches it.
164 if (!isHostId(hostId)) return
165 const url = sessionLink(hostId)
166 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
167 const tries = isWindows ? [['cmd', '/c', 'start', '', url]] : [['open', url], ['xdg-open', url]]
168 for (const argv of tries) {
169 const r = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => null)
170 if (r?.exitCode === 0) return
171 }
172 $.ui.toast(w.openFailed)
173}
174
175// ---- Repos: which one a file or a directory belongs to ----
176
177/** The git top level of a directory (or of the nearest parent that is a repo, for a folder a Write is about to
178 * create); '' when it is in none. Remembered per directory. */
179async function toplevelOf($: Api, start: string): Promise<string> {
180 const now = await $.clock.now()
181 let d = slashes(start)
182 const tried: string[] = []
183 for (let i = 0; i < 5 && d; i++) {
184 const hit = topCache.get(d)
185 let top = ''
186 if (hit && (hit.top || now - hit.at < NEGATIVE_MS)) top = hit.top
187 else {
188 top = slashes(await gitC($, d, ['rev-parse', '--show-toplevel']))
189 topCache.set(d, { top, at: now })
190 }
191 if (top) {
192 for (const t of tried) topCache.set(t, { top, at: now })
193 return top
194 }
195 tried.push(d)
196 const parent = d.replace(/\/[^/]*$/, '')
197 if (!parent || parent === d) break
198 d = parent
199 }
200 return ''
201}
202
203/** A repo's key and name: its normalized origin, else its absolute git directory (as session.start does). */
204async function identityOf($: Api, toplevel: string): Promise<{ repoKey: string; repoName: string }> {
205 const cached = identityCache.get(toplevel)
206 if (cached) return cached
207 const remote = await gitC($, toplevel, ['config', '--get', 'remote.origin.url'])
208 const commonDir = remote ? '' : await gitC($, toplevel, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
209 const repoKey = remote ? normalizeRemote(remote) : slashes(commonDir || toplevel)
210 const id = { repoKey, repoName: repoNameOf(repoKey, toplevel) }
211 if (identityCache.size > 200) identityCache.clear()
212 identityCache.set(toplevel, id)
213 return id
214}
215
216/** The session's entry for the repo at `toplevel`, made when it is the first time it is edited or entered. */
217async function noteRepo($: Api, toplevel: string, now: number): Promise<RepoEntry | null> {
218 if (!me || !toplevel) return null
219 const byTop = Object.values(me.repos).find(r => r.toplevel === toplevel)
220 const known = byTop ?? me.repos[(await identityOf($, toplevel)).repoKey]
221 if (known) {
222 known.seenAt = now
223 return known
224 }
225 const id = await identityOf($, toplevel)
226 const entry: RepoEntry = {
227 ...id,
228 toplevel,
229 branch: await gitC($, toplevel, ['rev-parse', '--abbrev-ref', 'HEAD']),
230 files: {},
231 seenAt: now,
232 }
233 me.repos[id.repoKey] = entry
234 return entry
235}
236
237// ---- Edits: through the file tools, and through the shell (what a command changed in the repos it works in) ----
238
239type Hit = { repoKey: string; repoName: string; rel: string }
240type Watched = { top: string; entry: RepoEntry; snap: Snapshot | null; startedAt: number }
241
242/** One repo's dirty files and, for at most 300 of them, a signature of mtime and size. Null when git can't say. */
243async function snapshot($: Api, top: string, prefer?: Snapshot | null): Promise<Snapshot | null> {
244 const r = await $.process
245 .run(['git', '-C', top, 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { timeoutMs: SNAPSHOT_MS })
246 .catch(() => null)
247 if (!r || r.exitCode !== 0) return null
248 const status = parsePorcelain(r.stdout)
249 const snap: Snapshot = {}
250 for (const [path, st] of Object.entries(status)) snap[path] = { status: st }
251 // The files the first snapshot signed come first, so the two can be compared.
252 const paths = Object.keys(snap).sort((a, b) => Number(prefer?.[b]?.sig !== undefined) - Number(prefer?.[a]?.sig !== undefined))
253 await Promise.all(
254 paths.slice(0, STAT_CAP).map(async path => {
255 const st = await $.fs.stat(`${top}/${path}`).catch(() => null)
256 if (st) snap[path]!.sig = `${st.mtimeMs}:${st.size}`
257 }),
258 )
259 return snap
260}
261
262/** Before a shell command: the repos to watch (home, the directories it names, those visited in the last 4 h),
263 * each with its dirty files. A command that enters a repo makes this session visit it. */
264async function beforeBash($: Api, command: string): Promise<Watched[]> {
265 if (!me) return []
266 const now = await $.clock.now()
267 const cwd = slashes(await $.session.cwd().catch(() => me!.cwd))
268 const named = new Set<string>()
269 for (const d of dirsInCommand(command, cwd, homeDir).slice(0, 6)) {
270 const top = await toplevelOf($, d)
271 if (top) named.add(top)
272 }
273 const tops = [me.repos[me.home]!.toplevel]
274 for (const top of named) tops.push(top)
275 const recent = Object.values(me.repos)
276 .filter(r => r.repoKey !== me!.home && now - (r.seenAt ?? 0) < FILE_WINDOW_MS)
277 .sort((a, b) => (b.seenAt ?? 0) - (a.seenAt ?? 0))
278 for (const r of recent) tops.push(r.toplevel)
279
280 const watched: Watched[] = []
281 let entered = false
282 for (const top of named) {
283 const before = Object.keys(me.repos).length
284 await noteRepo($, top, now)
285 if (Object.keys(me.repos).length !== before) entered = true
286 }
287 for (const top of [...new Set(tops)].slice(0, SNAPSHOT_REPOS)) {
288 const entry = Object.values(me.repos).find(r => r.toplevel === top) ?? (await noteRepo($, top, now))
289 if (entry) watched.push({ top, entry, snap: null, startedAt: now })
290 }
291 await Promise.all(watched.map(async t => (t.snap = await snapshot($, t.top))))
292 if (entered) save($).catch(() => {})
293 return watched
294}
295
296/** After it: what changed in those repos is this session's edit. */
297async function afterBash($: Api, watched: Watched[]): Promise<Hit[]> {
298 const now = await $.clock.now()
299 const hits: Hit[] = []
300 if (!watched.some(t => t.snap)) return hits
301 const peers = (await readPeers($, now).catch(() => peersSeen)).filter(p => p.sessionId !== me?.sessionId && isLive(p, now))
302 await Promise.all(
303 watched.map(async t => {
304 if (!t.snap) return
305 const after = await snapshot($, t.top, t.snap)
306 if (!after) return
307 for (const rel of diffSnapshots(t.snap, after)) {
308 // In a shared working copy another session may have written meanwhile: what it recorded is not ours.
309 if (peers.some(p => (p.repos[t.entry.repoKey]?.files[rel] ?? 0) >= t.startedAt)) continue
310 t.entry.files[rel] = now
311 t.entry.seenAt = now
312 hits.push({ repoKey: t.entry.repoKey, repoName: t.entry.repoName, rel })
313 }
314 }),
315 )
316 return hits
317}
318
319/** A write through Edit/Write/MultiEdit/NotebookEdit: the file's own repo, wherever it is. */
320async function afterEdit($: Api, input: { file_path?: string; notebook_path?: string }): Promise<Hit[]> {
321 if (!me) return []
322 const given = input.file_path ?? input.notebook_path
323 if (!given) return []
324 const path = resolvePath(slashes(await $.session.cwd().catch(() => me!.cwd)), given)
325 const top = await toplevelOf($, path.replace(/\/[^/]*$/, '') || '/')
326 const now = await $.clock.now()
327 const entry = await noteRepo($, top, now)
328 if (!entry) return []
329 let rel = repoRelative(top, path)
330 if (!rel) {
331 // git spells the top level with symbolic links resolved; the tool may not have.
332 const real = (await $.fs.stat(path, { resolve: true }).catch(() => null))?.realPath
333 rel = real ? repoRelative(top, real) : null
334 }
335 if (!rel) return []
336 entry.files[rel] = now
337 return [{ repoKey: entry.repoKey, repoName: entry.repoName, rel }]
338}
339
340// ---- Huddle ----
341
342/** The leader's lead as it is now on disk (a member reads it fresh: the agreement may be seconds old). */
343async function currentLead($: Api) {
344 if (!me?.huddle) return undefined
345 if (me.huddle.leaderId === me.sessionId) return me.huddle.lead
346 try {
347 return upgradePresence(JSON.parse(await $.fs.read(`${dir}/${me.huddle.leaderId}.json`)))?.huddle?.lead
348 } catch {
349 return leaderOf(me, peersSeen)?.huddle?.lead
350 }
351}
352
353/** Notes for edits that fall on a file the agreement gave to another live member; a toast the first time per file. */
354async function ownerNotes($: Api, hits: Hit[]): Promise<string[]> {
355 if (!me || hits.length === 0 || !me.huddle) return []
356 const lead = await currentLead($)
357 if (!lead) return []
358 const now = await $.clock.now()
359 const notes: string[] = []
360 for (const hit of hits) {
361 const ownerId = ownerOf(lead, hit.repoKey, hit.rel)
362 if (!ownerId || ownerId === me.sessionId) continue
363 const owner = peersSeen.find(p => p.sessionId === ownerId)
364 if (!owner || !isLive(owner, now)) continue
365 const name = nameOf(w, owner)
366 notes.push(w.ownerConflict(hit.rel, name))
367 const key = ownerKey(hit.repoKey, hit.rel)
368 if (!warned.has(key)) {
369 warned.add(key)
370 $.ui.toast(w.toastOwner(hit.rel, name))
371 }
372 }
373 return notes
374}
375
376/** A member's first message to anyone after joining confirms the huddle. */
377async function markAck($: Api) {
378 const h = me?.huddle
379 if (!me || !h || h.leaderId === me.sessionId || h.ackAt) return
380 h.ackAt = await $.clock.now()
381 await save($)
382}
383
384async function onReceive($: Api, e: { text: string }) {
385 if (!me) return
386 const now = await $.clock.now()
387 const add = parseAddMarker(e.text)
388 const lead = me.huddle?.leaderId === me.sessionId ? me.huddle.lead : undefined
389 if (add && lead && me.huddle?.id === add.id) {
390 lead.members = [...new Set([...lead.members, ...add.sessionIds.filter(id => id !== me!.sessionId)])]
391 await save($)
392 }
393 const marker = parseHuddleMarker(e.text)
394 if (marker && marker.leaderId !== me.sessionId) {
395 // A leader keeps its own huddle; anyone else follows the latest one that reached it.
396 const mine = me.huddle
397 if (!mine || (mine.id !== marker.id && mine.leaderId !== me.sessionId)) {
398 me.huddle = { id: marker.id, leaderId: marker.leaderId, joinedAt: now }
399 await save($)
400 }
401 }
402 if (busy && (marker || add)) {
403 const leader = marker && peersSeen.find(p => p.sessionId === marker.leaderId)
404 const from = leader ? nameOf(w, leader) : w.aMember
405 pending = [...pending, w.midTurnNote(from, compactForMidTurn(e.text))].slice(-PENDING_MAX)
406 }
407}
408
409async function onPrompt($: Api, e: { text: string; origin: { kind: string } }) {
410 busy = true
411 if (!me || !HUMAN_ORIGINS.has(e.origin.kind)) return
412 me.lastActiveAt = await $.clock.now()
413 const task = taskFromPrompt(e.text)
414 if (task && !me.task) {
415 me.task = task
416 // The first thing the person types says which language they work in.
417 inferred = inferLang(e.text.slice(0, 2000)) ?? inferred
418 envHostId = (await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')) || ''
419 const lang = await detectLang($)
420 w = words(lang)
421 me.lang = lang
422 }
423 save($).catch(() => {})
424}
425
426/** What a tool call adds after it ran: ownership notes for what it edited, and huddle messages that arrived mid-turn. */
427async function afterTool(
428 $: Api,
429 e: { tool: string; agentId?: string; file_path?: string; notebook_path?: string },
430 ran: ToolCallResult,
431 watched: Watched[],
432): Promise<ToolCallResult> {
433 if (ran.deny !== undefined) return ran
434 const notes: string[] = []
435 let hits: Hit[] = []
436 if (e.tool === 'Bash') hits = await afterBash($, watched)
437 else if (EDIT_TOOLS.has(e.tool) && !ran.isError) hits = await afterEdit($, e)
438 if (hits.length) {
439 notes.push(...(await ownerNotes($, hits)))
440 save($).catch(() => {})
441 refresh($).catch(() => {})
442 }
443 if (!e.agentId && pending.length) {
444 notes.push(...pending)
445 pending = []
446 }
447 return notes.length ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
448}
449
450/** One look at the peers. `timer` marks the clock's own tick: only those count toward re-reading the title. */
451async function refresh($: Api, timer = false) {
452 if (!me) return
453 if (!me.hostId && isHostId(envHostId) && (await $.session.surfaces().catch(() => [])).length > 0) me.hostId = envHostId
454 if (timer && shouldRefreshTitle(ticks++)) await refreshTitle($).catch(() => {})
455 const now = await $.clock.now()
456 await Promise.all(
457 activeRepos(me, now)
458 .slice(0, 5)
459 .map(async r => (r.branch = (await gitC($, r.toplevel, ['rev-parse', '--abbrev-ref', 'HEAD'])) || r.branch)),
460 )
461 await save($)
462 const peers = await readPeers($, now)
463 // A huddle nobody in it keeps alive is over (unless this session only just joined and the leader's file is not in yet).
464 if (me.huddle && now - me.huddle.joinedAt > 2 * 60_000 && !liveHuddle(me, peers, now)) me.huddle = undefined
465
466 const all = assessAll(me, peers, now)
467 const state = bandState(me, peers, now)
468 if (!sameJson(await read($, alerts), all)) await update($, alerts, () => all)
469 if (!sameJson(await read($, band), state)) await update($, band, () => state)
470
471 if (state.kind === 'alert') {
472 const snoozed = (await read($, snoozeUntil)) > now
473 for (const a of state.alerts) {
474 const key = `${a.sessionId}:${a.repoKey}:${a.level}`
475 if (a.level < 3 || toasted.has(key) || snoozed) continue
476 toasted.add(key)
477 $.ui.toast(w.toastClash(a.repoName, reasonOf(w, a)))
478 }
479 $.ui.status(w.status(state.alerts.length + 1, state.alerts[0]!.repoName))
480 } else {
481 $.ui.status(state.kind === 'huddle' ? w.statusHuddle(state.size) : undefined)
482 }
483}
484
485/** Starts a huddle with every live peer in the shared repos, or adds those who are not yet in the one that is live.
486 * Answers what the toast says. */
487async function coordinate($: Api): Promise<string> {
488 if (!me) return w.commandNotRepo
489 // Titles are what the person sees in the messages: take this session's and the peers' as they are now.
490 await refreshTitle($).catch(() => {})
491 const now = await $.clock.now()
492 const peers = await readPeers($, now)
493 const current = assessAll(me, peers, now)
494 const members = huddleMembers(me, peers, now)
495 const targets = current.filter(a => !members?.has(a.sessionId))
496 if (targets.length === 0) {
497 const told = current.length ? w.toastAllIn : w.toastNoPeers
498 $.ui.toast(told)
499 return told
500 }
501 if (!members) {
502 me.huddle = {
503 id: newHuddleId(me.sessionId, now),
504 leaderId: me.sessionId,
505 joinedAt: now,
506 lead: { members: [], owners: {}, createdAt: now },
507 }
508 }
509 const h = me.huddle!
510 const isLeader = h.leaderId === me.sessionId
511 if (isLeader && !h.lead) h.lead = { members: [], owners: {}, createdAt: now }
512
513 const sent: string[] = []
514 const failed: string[] = []
515 for (const a of targets) {
516 const r = await $.session
517 .send({ to: { sessionId: a.sessionId }, text: peerMessage(w, me, a, now, { id: h.id, leaderId: h.leaderId }) })
518 .catch(err => ({ isDelivered: false as const, reason: String(err) }))
519 if (r.isDelivered) sent.push(a.sessionId)
520 else failed.push(`${a.sessionId.slice(0, 8)}: ${r.reason}`)
521 }
522 const names = targets.filter(a => sent.includes(a.sessionId)).map(a => nameOf(w, a)).join(', ')
523 if (sent.length) {
524 if (isLeader) h.lead!.members = [...new Set([...h.lead!.members, ...sent])]
525 // A member that adds tells the leader, who keeps the list and takes the replies.
526 else void $.session.send({ to: { sessionId: h.leaderId }, text: w.addedNotice(names, addMarker(h.id, sent)) }).catch(() => null)
527 }
528 await save($)
529 await update($, lastCoord, () => ({ at: now, sent: sent.length, failed }))
530 const told = failed.length ? w.toastPartial(sent.length, failed.length) : members ? w.toastAdded(sent.length) : w.toastSent(sent.length)
531 $.ui.toast(told)
532 if (!members) void $.prompt.submit({ text: leadPrompt(w, me, targets, sent) })
533 else if (isLeader && sent.length) void $.prompt.submit({ text: w.leadAdded(names) })
534 refresh($).catch(() => {})
535 return told
536}
537
538type AgreeInput = { summary?: unknown; owners?: unknown }
539
540/** The `agree` tool: the leader records who owns which file and tells every member. */
541async function agree($: Api, input: AgreeInput): Promise<{ result: string } | { deny: string }> {
542 const h = me?.huddle
543 if (!me || !h) return { deny: w.agreeNoHuddle }
544 if (h.leaderId !== me.sessionId || !h.lead) return { deny: w.agreeNotLeader }
545 const summary = typeof input.summary === 'string' ? input.summary.trim() : ''
546 const given = Array.isArray(input.owners) ? (input.owners as { repo?: unknown; path?: unknown; sessionId?: unknown }[]) : []
547 if (!summary || given.length === 0) return { deny: w.agreeBadInput }
548
549 const now = await $.clock.now()
550 const peers = await readPeers($, now)
551 const everyone = [me, ...peers]
552 const repos = everyone.flatMap(p => Object.values(p.repos))
553 const owners: Record<string, string> = {}
554 const lines: string[] = []
555 for (const o of given) {
556 const sessionId = typeof o.sessionId === 'string' ? o.sessionId : ''
557 let path = typeof o.path === 'string' ? slashes(o.path).replace(/^\.\//, '') : ''
558 if (!ID_RE.test(sessionId) || !path) return { deny: w.agreeBadInput }
559 const asked = typeof o.repo === 'string' ? o.repo.trim() : ''
560 let repo = asked
561 ? repos.find(r => r.repoKey === asked) ?? repos.find(r => r.repoName.toLowerCase() === asked.toLowerCase())
562 : me.repos[me.home]
563 if (!repo) return { deny: w.agreeBadRepo(asked) }
564 if (!asked) {
565 // An absolute path names its own repo.
566 const inside = repos.find(r => repoRelative(r.toplevel, path) !== null)
567 if (inside && path.startsWith('/')) repo = inside
568 }
569 path = repoRelative(repo.toplevel, path) ?? path
570 owners[ownerKey(repo.repoKey, path)] = sessionId
571 const owner = everyone.find(p => p.sessionId === sessionId)
572 lines.push(`- ${repo.repoName}/${path} → ${owner ? nameOf(w, owner) : sessionId.slice(0, 8)} (${sessionId})`)
573 }
574
575 h.lead.owners = owners
576 h.lead.summary = summary
577 h.lead.agreedAt = now
578 const text = w.agreeMessage({ summary, owners: lines, marker: huddleMarker(h.id, me.sessionId) })
579 const memberIds = new Set([...h.lead.members, ...peers.filter(p => p.huddle?.id === h.id).map(p => p.sessionId)])
580 memberIds.delete(me.sessionId)
581 let sent = 0
582 let failed = 0
583 for (const id of memberIds) {
584 if (!peers.some(p => p.sessionId === id && isLive(p, now))) continue
585 const r = await $.session.send({ to: { sessionId: id }, text }).catch(() => ({ isDelivered: false as const }))
586 if (r.isDelivered) sent++
587 else failed++
588 }
589 await save($)
590 refresh($).catch(() => {})
591 return { result: w.agreeDone(sent, failed) }
592}
593
594export const register: Register = on => {
595 on('session.start', async ($, e, next) => {
596 const started = await next(e)
597 const cwd = await $.session.cwd()
598 const toplevel = await git($, cwd, ['rev-parse', '--show-toplevel'])
599 if (!toplevel) {
600 w = words(await detectLang($))
601 return started
602 }
603
604 const commonDir = await git($, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
605 const remote = await git($, cwd, ['config', '--get', 'remote.origin.url'])
606 const repoKey = remote ? normalizeRemote(remote) : slashes(commonDir)
607 const now = await $.clock.now()
608 homeDir = slashes((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '')
609 dir = `${homeDir}/.claude/huddle/sessions`
610
611 const home: RepoEntry = {
612 repoKey,
613 repoName: repoNameOf(repoKey, slashes(toplevel)),
614 toplevel: slashes(toplevel),
615 branch: await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
616 files: {},
617 seenAt: now,
618 }
619 me = {
620 v: 2,
621 sessionId: await $.session.id(),
622 cwd,
623 home: repoKey,
624 repos: { [repoKey]: home },
625 task: '',
626 hostId: undefined,
627 startedAt: now,
628 lastSeen: now,
629 lastActiveAt: now,
630 }
631 // A reload keeps the session: pick up what this session already recorded.
632 try {
633 const saved = upgradePresence(JSON.parse(await $.fs.read(`${dir}/${me.sessionId}.json`)))
634 if (saved) {
635 me.task = saved.task
636 me.title = saved.title
637 me.huddle = saved.huddle
638 me.startedAt = saved.startedAt
639 me.lastActiveAt = saved.lastActiveAt
640 me.repos = { ...saved.repos, [repoKey]: { ...home, files: saved.repos[repoKey]?.files ?? {} } }
641 if (saved.task) inferred = inferLang(saved.task)
642 }
643 } catch {
644 // first start
645 }
646 const lang = await detectLang($)
647 w = words(lang)
648 me.lang = lang
649
650 await $.command.register({ name: 'huddle', description: w.commandDescription })
651 await $.tool.register({ name: 'peers', description: w.toolPeers })
652 await $.tool.register({
653 name: 'send',
654 description: w.toolSend,
655 inputSchema: {
656 type: 'object',
657 properties: { sessionId: { type: 'string' }, text: { type: 'string' } },
658 required: ['sessionId', 'text'],
659 },
660 })
661 await $.tool.register({
662 name: 'agree',
663 description: w.toolAgree,
664 inputSchema: {
665 type: 'object',
666 properties: {
667 summary: { type: 'string' },
668 owners: {
669 type: 'array',
670 items: {
671 type: 'object',
672 properties: { repo: { type: 'string' }, path: { type: 'string' }, sessionId: { type: 'string' } },
673 required: ['path', 'sessionId'],
674 },
675 },
676 },
677 required: ['summary', 'owners'],
678 },
679 })
680
681 await refresh($, true)
682 $.clock.every(TICK_MS, () => void refresh($, true).catch(() => {}))
683 return started
684 })
685
686 on('command.run', { command: 'huddle' }, async $ => ({ text: await coordinate($) }))
687
688 on('prompt.submit', async ($, e, next) => {
689 // The task is what a person asked: typed, sent from a phone or an SDK host; never a peer's or a plugin's text.
690 await onPrompt($, e).catch(() => {})
691 return next(e)
692 })
693
694 on('turn.complete', async ($, e, next) => {
695 if (!e.agentId) busy = false
696 return next(e)
697 })
698
699 // Outside the desktop app the title comes with the prompt (a /rename in the terminal, say).
700 on('classic.UserPromptSubmit', async ($, e, next) => {
701 if (me && e.session_title && e.session_title !== me.title) {
702 me.title = e.session_title.slice(0, 120)
703 save($).catch(() => {})
704 }
705 return next(e)
706 })
707
708 on('tool.call', async ($, e, next) => {
709 // A shell command is looked at before and after: what changed in between is this session's edit.
710 let watched: Watched[] = []
711 try {
712 if (me) {
713 me.lastActiveAt = await $.clock.now()
714 if (e.tool === 'Bash') watched = await beforeBash($, e.command)
715 }
716 } catch {
717 // watching is best effort; the command still runs
718 }
719 const ran = await next(e)
720 if (!me) return ran
721 // Recording never changes the tool's result: the edit already happened.
722 return afterTool($, e as never, ran, watched).catch(() => ran)
723 })
724
725 on('tool.call', { tool: 'mcp__huddle__peers' }, async $ => {
726 try {
727 const list = await read($, alerts)
728 return { result: list.length ? JSON.stringify(list, null, 2) : w.peersNone }
729 } catch (err) {
730 return { deny: `huddle: ${String(err)}` }
731 }
732 })
733
734 on('tool.call', { tool: 'mcp__huddle__send' }, async ($, e) => {
735 const { sessionId, text } = e as unknown as { sessionId: string; text: string }
736 try {
737 const r = await $.session.send({ to: { sessionId }, text })
738 if (r.isDelivered) await markAck($).catch(() => {})
739 return r.isDelivered ? { result: w.delivered } : { deny: w.notDelivered(r.reason) }
740 } catch (err) {
741 return { deny: w.notDelivered(String(err)) }
742 }
743 })
744
745 on('tool.call', { tool: 'mcp__huddle__agree' }, async ($, e) => {
746 try {
747 return await agree($, e as unknown as AgreeInput)
748 } catch (err) {
749 return { deny: `huddle: ${String(err)}` }
750 }
751 })
752
753 // Joining a huddle is a matter of reading its marker; the message itself is always delivered.
754 on('session.receive', async ($, e, next) => {
755 await onReceive($, e).catch(() => {})
756 return next(e)
757 })
758
759 on('session.send', async ($, e, next) => {
760 const r = await next(e)
761 if (r.isDelivered) await markAck($).catch(() => {})
762 return r
763 })
764
765 on('session.end', async ($, e, next) => {
766 if (me) {
767 me.ended = true
768 await save($).catch(() => {})
769 }
770 return next(e)
771 })
772
773 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
774 const state = await read($, band)
775 const now = await $.clock.now()
776 if (!me || e.props.hasSurvey || state.kind === 'none') return next(e)
777 const { Box, Button, Text } = $.ui.resolve(e)
778
779 if (state.kind === 'huddle') {
780 const led = state.leader
781 return (
782 <Box flexDirection="column" marginTop={1}>
783 <Box flexDirection="row" gap={1}>
784 <Text color="success">
785 ✓ {w.huddleWith(state.size)} · {state.leaderIsMe ? w.ledByYou : w.ledBy}
786 </Text>
787 {!state.leaderIsMe &&
788 (led.hostId ? (
789 <Button key="open-leader" plain onPress={() => openSession($, led.hostId)}>
790 <Text color="success" underline>
791 {nameOf(w, led)}
792 </Text>
793 </Button>
794 ) : (
795 <Text color="success">{nameOf(w, led)}</Text>
796 ))}
797 <Text dimColor>· {w.confirmed(state.confirmed, state.size)}</Text>
798 </Box>
799 {state.conflicts.slice(0, 2).map(c => (
800 <Text color="warning" dimColor>
801 ▲ {w.editedOwned(nameOf(w, c.editor), c.file, nameOf(w, c.owner))}
802 </Text>
803 ))}
804 </Box>
805 )
806 }
807
808 if ((await read($, snoozeUntil)) > now) return next(e)
809 const list = state.alerts
810 const top = list[0]!.level
811 const done = await read($, lastCoord)
812
813 return (
814 <Box flexDirection="column" marginTop={1}>
815 {list.slice(0, 3).map(a => (
816 <Box flexDirection="row" gap={1}>
817 <Text color={LEVEL_COLOR[a.level]}>
818 {LEVEL_MARK[a.level]} {a.level === 3 ? w.clash : w.otherAgent} {w.in} {a.repoName} · {reasonOf(w, a)}
819 {a.overlap.length ? ` (${a.overlap.slice(0, 3).join(', ')}${a.overlap.length > 3 ? '…' : ''})` : ''} ·
820 </Text>
821 {a.hostId ? (
822 <Button key={`open-${a.sessionId}`} plain onPress={() => openSession($, a.hostId)}>
823 <Text color={LEVEL_COLOR[a.level]} underline>
824 {nameOf(w, { title: a.title, task: a.task.slice(0, 60) })}
825 </Text>
826 </Button>
827 ) : (
828 <Text color={LEVEL_COLOR[a.level]}>{nameOf(w, { title: a.title, task: a.task.slice(0, 60) })}</Text>
829 )}
830 <Text dimColor>· {a.branch}</Text>
831 </Box>
832 ))}
833 {list.length > 3 && <Text dimColor> {w.more(list.length - 3)}</Text>}
834 <Box flexDirection="row" gap={1}>
835 {top === 3 && <Text color="error" bold>{w.recommend}</Text>}
836 <Button key="coord" variant={top === 3 ? 'primary' : undefined} onPress={() => coordinate($)}>
837 {state.hasHuddle ? w.addToHuddle : w.coordinate}
838 </Button>
839 <Button key="snooze" onPress={() => update($, snoozeUntil, () => now + SNOOZE_MS)}>
840 {w.snooze}
841 </Button>
842 {done && now - done.at < 10 * 60_000 && (
843 <Text dimColor>
844 {w.sentTo(done.sent)}
845 {done.failed.length ? w.failedN(done.failed.length) : ''}
846 </Text>
847 )}
848 </Box>
849 </Box>
850 )
851 })
852}
853hooks/i18n.ts 236 lines1/**
2 * Huddle's words in English and Spanish, picked by Claude Code's `language` setting, then by what the person types,
3 * then the locale (nothing to configure). Messages to other agents go out in the sender's language.
4 */
5import type { AlertKind } from '../types'
6
7export type Lang = 'en' | 'es'
8
9const EN = {
10 reason: (kind: AlertKind, n: number): string =>
11 kind === 'overlap'
12 ? `both edited ${n} file${n === 1 ? '' : 's'}`
13 : kind === 'hot'
14 ? 'both touch single-owner files (lockfile, package.json, migrations, schema)'
15 : kind === 'checkout'
16 ? 'same working copy (no worktree of its own)'
17 : 'same repo, another copy or worktree',
18 none: 'none yet',
19 noTask: '(no task stated)',
20 clash: 'Clash',
21 otherAgent: 'Another agent',
22 in: 'in',
23 recommend: 'Coordinating is recommended.',
24 coordinate: 'Coordinate',
25 addToHuddle: 'Add to huddle',
26 snooze: 'Snooze 30 min',
27 more: (n: number) => `+${n} more`,
28 sentTo: (n: number) => `Sent to ${n}`,
29 failedN: (n: number) => ` · ${n} failed`,
30 status: (n: number, repo: string) => `👥 ${n} agents in ${repo}`,
31 statusHuddle: (n: number) => `👥 Huddle · ${n} agents`,
32 huddleWith: (n: number) => `Huddle with ${n} sessions`,
33 aMember: 'a huddle member',
34 ledBy: 'led by',
35 ledByYou: 'led by you',
36 confirmed: (c: number, n: number) => `${c}/${n} confirmed`,
37 editedOwned: (editor: string, file: string, owner: string) => `${editor} edited ${file} (owner ${owner})`,
38 toastOwner: (file: string, owner: string) => `You edited ${file}, which the huddle gave to ${owner}.`,
39 toastAllIn: 'Everyone active here is already in the huddle.',
40 toastAdded: (n: number) => `Added ${n} session${n === 1 ? '' : 's'} to the huddle.`,
41 toastClash: (repo: string, reason: string) => `Clash with another agent in ${repo}: ${reason}. Coordinate from the band.`,
42 toastNoPeers: 'No other agents are active in this repo anymore.',
43 openFailed: "Couldn't open that session. Find it by name in the sidebar.",
44 toastSent: (n: number) => `Huddle sent to ${n} session${n === 1 ? '' : 's'}.`,
45 toastPartial: (sent: number, failed: number) => `Huddle sent to ${sent}; ${failed} failed.`,
46 commandDescription: 'Coordinate with the other Claude sessions working in this repo',
47 commandNotRepo: 'Huddle only works inside a git repository.',
48 toolPeers:
49 'Lists the other Claude sessions active in this same repo: id, task, branch, clash level (1 same repo, 2 same working copy, 3 same files) and the files in conflict.',
50 toolSend:
51 'Sends a message to another Claude session in this repo to coordinate work (use the sessionId the peers tool gives).',
52 toolAgree:
53 "Huddle leader only: records the agreement (one owner per disputed file, plus a short summary) and sends it to every member. Call it once, when the members' replies are in.",
54 agreeNoHuddle: 'There is no huddle led by this session.',
55 agreeNotLeader: 'Only the huddle leader can call agree.',
56 agreeBadInput: 'agree needs a summary and at least one owner with a path and a sessionId.',
57 agreeBadRepo: (repo: string) => `Unknown repo «${repo}». Use a repo name or key from the peers tool.`,
58 agreeDone: (sent: number, failed: number) =>
59 `Agreement recorded and sent to ${sent} session${sent === 1 ? '' : 's'}${failed ? `; ${failed} failed` : ''}.`,
60 peersNone: 'No other sessions are active in this repo.',
61 delivered: 'Delivered.',
62 notDelivered: (why: string) => `Not delivered: ${why}`,
63 peerMessage: (p: {
64 repo: string; level: number; reason: string; id: string; task: string; branch: string; path: string
65 files: string; clash: string | null; leader: string; marker: string
66 }) =>
67 [
68 `[huddle] The user asked to coordinate the sessions working in the repo ${p.repo} (level ${p.level}: ${p.reason}).`,
69 `Initiating session (id ${p.id}): ${p.task} · branch ${p.branch} · ${p.path}`,
70 `Files it has edited: ${p.files}.`,
71 p.clash ? `Files in conflict: ${p.clash}.` : '',
72 'Protocol:',
73 `1. Reply to the leader (sessionId ${p.leader}) with the huddle send tool or with SendMessage: your task, the files you edited and the ones you plan to touch.`,
74 '2. Agree on one owner for each disputed file and on the order of work: whoever goes first finishes and commits first; the other updates (pull/rebase) before touching those files. Lockfile, package.json, migrations, schema and generated files have a single owner.',
75 '3. If you share the same working copy: nobody runs checkout, stash, reset or installs dependencies without telling the other, and you commit only your own files with a pathspec (git commit -- <files>), never everything that is dirty.',
76 "4. Don't edit the other session's files without agreement. While the agreement lands, keep working on what doesn't clash.",
77 '5. Respect your original instruction. If your task already includes committing, pushing or publishing, still do it, but in the agreed order and carefully (update first, verify after). If it was not asked of you, coordinating is not a reason to do it. Coordinating is never a reason to stop doing what you were asked.',
78 'The leader closes with the agreement (the huddle agree tool), which reaches everyone.',
79 p.marker,
80 ]
81 .filter(Boolean)
82 .join('\n'),
83 addedNotice: (names: string, marker: string) =>
84 `[huddle] I added these sessions to the huddle: ${names}. They received the protocol and will reply to you, the leader.\n${marker}`,
85 agreeMessage: (p: { summary: string; owners: string[]; marker: string }) =>
86 [
87 '[huddle] The leader recorded the agreement. Follow it and keep doing your original task.',
88 p.summary,
89 p.owners.length ? `Owners:\n${p.owners.join('\n')}` : '',
90 p.marker,
91 ]
92 .filter(Boolean)
93 .join('\n'),
94 ownerConflict: (file: string, owner: string) =>
95 `[huddle] Heads-up: in the huddle agreement ${file} belongs to ${owner}, and you just edited it. Stop editing it; tell the owner with the huddle send tool, or hand them the change.`,
96 midTurnNote: (from: string, text: string) =>
97 `[huddle] A message from ${from} arrived while you were working:\n${text}`,
98 leadIntro: (repo: string) =>
99 `[huddle] The user asked to coordinate this session with the others working in ${repo}. I already sent them the coordination protocol.`,
100 leadRow: (p: { id: string; level: number; reason: string; task: string; branch: string; clash: string | null; failed: boolean }) =>
101 `- ${p.id} · level ${p.level} (${p.reason}) · ${p.task} · branch ${p.branch}` +
102 (p.clash ? ` · conflict in: ${p.clash}` : '') +
103 (p.failed ? ' · ⚠ the message could not be delivered' : ''),
104 leadClose:
105 'You lead. The protocol already went out: do not message them until they reply. When the replies are in, call the huddle agree tool once with one owner per disputed file and a 3-line summary; it reaches everyone. Then keep doing your task and give the user the summary. Keep to your original instruction: commit or publish only if that was already part of your task, and then in the agreed order.',
106 leadAdded: (names: string) =>
107 `[huddle] I added these sessions to the huddle: ${names}. The protocol already went out: do not message them until they reply. When their replies are in, call the huddle agree tool again with the updated owners and summary.`,
108}
109
110export type Words = typeof EN
111
112const ES: Words = {
113 reason: (kind, n) =>
114 kind === 'overlap'
115 ? `ambos editaron ${n} archivo${n === 1 ? '' : 's'}`
116 : kind === 'hot'
117 ? 'ambos tocan archivos de dueño único (lockfile, package.json, migraciones, schema)'
118 : kind === 'checkout'
119 ? 'misma copia de trabajo (sin worktree propio)'
120 : 'mismo repo, otra copia o worktree',
121 none: 'ninguno aún',
122 noTask: '(sin tarea declarada)',
123 clash: 'Choque',
124 otherAgent: 'Otro agente',
125 in: 'en',
126 recommend: 'Se recomienda coordinar.',
127 coordinate: 'Coordinar',
128 addToHuddle: 'Añadir al huddle',
129 snooze: 'Silenciar 30 min',
130 more: n => `+${n} más`,
131 sentTo: n => `Enviado a ${n}`,
132 failedN: n => ` · falló ${n}`,
133 status: (n, repo) => `👥 ${n} agentes en ${repo}`,
134 statusHuddle: n => `👥 Huddle · ${n} agentes`,
135 huddleWith: n => `En huddle con ${n} sesiones`,
136 aMember: 'un miembro del huddle',
137 ledBy: 'lidera',
138 ledByYou: 'lo lideras tú',
139 confirmed: (c, n) => `${c}/${n} confirmaron`,
140 editedOwned: (editor, file, owner) => `${editor} editó ${file} (dueño ${owner})`,
141 toastOwner: (file, owner) => `Editaste ${file}, que el huddle asignó a ${owner}.`,
142 toastAllIn: 'Todos los activos aquí ya están en el huddle.',
143 toastAdded: n => `Se añadió ${n} sesi${n === 1 ? 'ón' : 'ones'} al huddle.`,
144 toastClash: (repo, reason) => `Choque con otro agente en ${repo}: ${reason}. Coordina desde la banda.`,
145 toastNoPeers: 'Ya no hay otros agentes activos en este repo.',
146 openFailed: 'No pude abrir esa sesión. Búscala por nombre en el sidebar.',
147 toastSent: n => `Huddle enviado a ${n} sesi${n === 1 ? 'ón' : 'ones'}.`,
148 toastPartial: (sent, failed) => `Huddle enviado a ${sent}; falló con ${failed}.`,
149 commandDescription: 'Coordina con las otras sesiones de Claude que trabajan en este repo',
150 commandNotRepo: 'Huddle solo funciona dentro de un repositorio git.',
151 toolPeers:
152 'Lista las otras sesiones de Claude activas en este mismo repo: id, tarea, rama, nivel de choque (1 mismo repo, 2 misma copia, 3 mismos archivos) y archivos en choque.',
153 toolSend:
154 'Envía un mensaje a otra sesión de Claude del mismo repo para coordinar trabajo (usa el sessionId que da la herramienta peers).',
155 toolAgree:
156 'Solo el líder del huddle: registra el acuerdo (un dueño por archivo en disputa y un resumen corto) y lo envía a todos los miembros. Llámala una vez, cuando ya llegaron las respuestas.',
157 agreeNoHuddle: 'Esta sesión no lidera ningún huddle.',
158 agreeNotLeader: 'Solo el líder del huddle puede llamar a agree.',
159 agreeBadInput: 'agree necesita un resumen y al menos un dueño con path y sessionId.',
160 agreeBadRepo: repo => `Repo desconocido «${repo}». Usa un nombre o clave de repo de la herramienta peers.`,
161 agreeDone: (sent, failed) =>
162 `Acuerdo registrado y enviado a ${sent} sesi${sent === 1 ? 'ón' : 'ones'}${failed ? `; falló con ${failed}` : ''}.`,
163 peersNone: 'No hay otras sesiones activas en este repo.',
164 delivered: 'Entregado.',
165 notDelivered: why => `No se entregó: ${why}`,
166 peerMessage: p =>
167 [
168 `[huddle] El usuario pidió coordinar las sesiones que trabajan en el repo ${p.repo} (nivel ${p.level}: ${p.reason}).`,
169 `Sesión que inicia (id ${p.id}): ${p.task} · rama ${p.branch} · ${p.path}`,
170 `Archivos que ha editado: ${p.files}.`,
171 p.clash ? `Archivos en choque: ${p.clash}.` : '',
172 'Protocolo:',
173 `1. Contesta al líder (sessionId ${p.leader}) con la herramienta send de huddle o con SendMessage: tu tarea, los archivos que editaste y los que planeas tocar.`,
174 '2. Acuerden un dueño por cada archivo en disputa y el orden de trabajo: quien va primero termina y commitea primero; el otro actualiza (pull/rebase) antes de tocar esos archivos. Lockfile, package.json, migraciones, schema y generados tienen un solo dueño.',
175 '3. Si comparten la misma copia de trabajo: nadie hace checkout, stash, reset ni instala dependencias sin avisar al otro, y tú commiteas solo tus archivos con pathspec (git commit -- <archivos>), nunca todo lo que esté sucio.',
176 '4. No edites archivos del otro sin acuerdo. Mientras llega el acuerdo, sigue con lo que no choca.',
177 '5. Respeta tu instrucción original. Si tu tarea ya incluye commitear, hacer push o publicar, hazlo igual, pero en el orden acordado y con cuidado (actualiza antes, verifica después). Si no te lo pidieron, coordinarte no es razón para hacerlo. Coordinarte nunca es razón para dejar de hacer lo que te pidieron.',
178 'El líder cierra con el acuerdo (la herramienta agree de huddle), que le llega a todos.',
179 p.marker,
180 ]
181 .filter(Boolean)
182 .join('\n'),
183 addedNotice: (names, marker) =>
184 `[huddle] Añadí estas sesiones al huddle: ${names}. Recibieron el protocolo y te contestarán a ti, el líder.\n${marker}`,
185 agreeMessage: p =>
186 [
187 '[huddle] El líder registró el acuerdo. Síguelo y continúa con tu tarea original.',
188 p.summary,
189 p.owners.length ? `Dueños:\n${p.owners.join('\n')}` : '',
190 p.marker,
191 ]
192 .filter(Boolean)
193 .join('\n'),
194 ownerConflict: (file, owner) =>
195 `[huddle] Ojo: en el acuerdo del huddle ${file} es de ${owner}, y acabas de editarlo. Deja de editarlo; avísale al dueño con la herramienta send de huddle o entrégale el cambio.`,
196 midTurnNote: (from, text) => `[huddle] Llegó un mensaje de ${from} mientras trabajabas:\n${text}`,
197 leadIntro: repo =>
198 `[huddle] El usuario pidió coordinar esta sesión con las otras que trabajan en ${repo}. Ya les envié el protocolo de coordinación.`,
199 leadRow: p =>
200 `- ${p.id} · nivel ${p.level} (${p.reason}) · ${p.task} · rama ${p.branch}` +
201 (p.clash ? ` · choque en: ${p.clash}` : '') +
202 (p.failed ? ' · ⚠ no se le pudo entregar el mensaje' : ''),
203 leadClose:
204 'Lidera tú. El protocolo ya salió: no les escribas hasta que contesten. Cuando lleguen las respuestas, llama una sola vez a la herramienta agree de huddle con un dueño por archivo en disputa y un resumen de 3 líneas; le llega a todos. Luego sigue con tu tarea y dale el resumen al usuario. Respeta tu instrucción original: commitea o publica solo si eso ya era parte de tu tarea, y entonces en el orden acordado.',
205 leadAdded: names =>
206 `[huddle] Añadí estas sesiones al huddle: ${names}. El protocolo ya salió: no les escribas hasta que contesten. Cuando lleguen sus respuestas, llama otra vez a la herramienta agree de huddle con los dueños y el resumen actualizados.`,
207}
208
209export const words = (lang: Lang): Words => (lang === 'es' ? ES : EN)
210
211/**
212 * Spanish when Spanish markers outnumber English ones in what the person typed, English when the reverse, null on a
213 * tie (a prompt like "ok" says nothing).
214 */
215export function inferLang(text: string): Lang | null {
216 const t = text.toLowerCase()
217 const es = (t.match(/[¿¡áéíóúñ]/g) ?? []).length + (t.match(ES_WORDS) ?? []).length
218 const en = (t.match(EN_WORDS) ?? []).length
219 return es > en ? 'es' : en > es ? 'en' : null
220}
221
222const ES_WORDS = /(?<![\p{L}\p{N}])(?:que|de|la|el|los|las|para|por|una|con|está|ya|sí|también|pero|como|esto|esta|cómo)(?![\p{L}\p{N}])/gu
223const EN_WORDS = /(?<![\p{L}\p{N}])(?:the|and|to|is|of|for|with|this|that|please|it|in|on|are|you)(?![\p{L}\p{N}])/gu
224
225/**
226 * Claude Code's `language` setting, then what the person's own prompts read as, then LC_ALL/LANG; English otherwise.
227 * An explicit 'en' or 'es' first wins (tests).
228 */
229export function pickLang(option: unknown, settingsLanguage: unknown, locale: string | null, inferred?: Lang | null): Lang {
230 if (option === 'en' || option === 'es') return option
231 const s = typeof settingsLanguage === 'string' ? settingsLanguage.toLowerCase() : ''
232 if (s) return s.startsWith('es') || s.startsWith('span') || s.startsWith('espa') ? 'es' : 'en'
233 if (inferred) return inferred
234 return (locale ?? '').toLowerCase().startsWith('es') ? 'es' : 'en'
235}
236hooks/logic.ts 558 lines1import type { Alert, AlertKind, BandState, Level, OwnerConflict, Who } from '../types'
2import type { Lang, Words } from './i18n'
3
4/** What a session knows of one repo it works in: its working copy there, its branch, the files it edited. */
5export type RepoEntry = {
6 repoKey: string
7 repoName: string
8 toplevel: string
9 branch: string
10 files: Record<string, number>
11 /** When the session last edited or entered this repo. A repo not seen for the file window stops counting. */
12 seenAt?: number
13}
14
15/** What the leader decided: who owns which disputed file. `owners` keys are `repoKey::relPath`. */
16export type HuddleLead = {
17 members: string[]
18 owners: Record<string, string>
19 summary?: string
20 createdAt: number
21 /** When `agree` last recorded the owners: only edits after it can break the agreement. */
22 agreedAt?: number
23}
24
25export type HuddleInfo = {
26 id: string
27 leaderId: string
28 joinedAt: number
29 ackAt?: number
30 lead?: HuddleLead
31}
32
33// One file per live session under ~/.claude/huddle/sessions/<sessionId>.json.
34export type Presence = {
35 v: 2
36 sessionId: string
37 cwd: string
38 /** The repo of the session's working directory; always in `repos`. */
39 home: string
40 repos: Record<string, RepoEntry>
41 task: string
42 title?: string
43 hostId?: string
44 lang?: Lang
45 huddle?: HuddleInfo
46 startedAt: number
47 lastSeen: number
48 /** Last human prompt or tool call: a session open but untouched for hours is idle. */
49 lastActiveAt: number
50 ended?: boolean
51}
52
53export const LIVE_MS = 60_000
54export const FILE_WINDOW_MS = 4 * 60 * 60_000
55export const IDLE_MS = 2 * 60 * 60_000
56
57// Files only one session should touch at a time (single-owner).
58const HOT = [
59 /(^|\/)package\.json$/,
60 /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/,
61 /(^|\/)migrations?\//,
62 /(^|\/)(schema\.prisma|drizzle\.config\.\w+)$/,
63 /(^|\/)(schema|db\/schema)\.\w+$/,
64 /(^|\/)generated\//,
65]
66
67export const isHot = (path: string) => HOT.some(re => re.test(path))
68
69// git remote spellings collapse to one key: git@github.com:o/r.git == https://github.com/o/r
70export function normalizeRemote(url: string): string {
71 return url
72 .trim()
73 .replace(/^[a-z+]+:\/\//, '')
74 .replace(/^[^@/]+@/, '')
75 .replace(':', '/')
76 .replace(/\/+$/, '')
77 .replace(/\.git$/, '')
78 .toLowerCase()
79}
80
81const recent = (files: Record<string, number>, now: number) =>
82 Object.keys(files).filter(f => now - (files[f] ?? 0) < FILE_WINDOW_MS)
83
84export function isLive(p: Presence, now: number): boolean {
85 return !p.ended && now - p.lastSeen < LIVE_MS
86}
87
88export const isIdle = (p: Presence, now: number): boolean => now - p.lastActiveAt > IDLE_MS
89
90/**
91 * A presence file as this version reads it. A session still on 0.2.x writes `v: 1` (one repo, flat); it is read as
92 * a session whose only repo is its home. Anything else is skipped.
93 */
94export function upgradePresence(raw: unknown): Presence | null {
95 const p = raw as Record<string, any> | null
96 if (!p || typeof p !== 'object' || typeof p.sessionId !== 'string') return null
97 if (p.v === 2) {
98 if (!p.repos || typeof p.repos !== 'object' || typeof p.home !== 'string') return null
99 return { ...p, lastActiveAt: typeof p.lastActiveAt === 'number' ? p.lastActiveAt : p.lastSeen } as Presence
100 }
101 if (p.v !== 1 || typeof p.repoKey !== 'string') return null
102 const { files, toplevel, repoName, branch, repoKey, ...rest } = p
103 return {
104 ...rest,
105 v: 2,
106 home: repoKey,
107 repos: { [repoKey]: { repoKey, repoName, toplevel, branch, files: files ?? {}, seenAt: p.lastSeen } },
108 lastActiveAt: p.lastSeen,
109 } as Presence
110}
111
112/** The repos that count right now: the home one always, the others while they were seen within the file window. */
113export function activeRepos(p: Presence, now: number): RepoEntry[] {
114 return Object.values(p.repos).filter(r => r.repoKey === p.home || now - (r.seenAt ?? p.lastSeen) < FILE_WINDOW_MS)
115}
116
117function assessRepo(mine: RepoEntry, theirs: RepoEntry, now: number): Pick<Alert, 'level' | 'kind' | 'overlap'> {
118 const theirFiles = new Set(recent(theirs.files, now))
119 const mineFiles = recent(mine.files, now)
120 const overlap = mineFiles.filter(f => theirFiles.has(f)).sort()
121 const bothHot = mineFiles.some(isHot) && [...theirFiles].some(isHot)
122 const sameCheckout = mine.toplevel === theirs.toplevel
123 const [level, kind]: [Level, AlertKind] =
124 overlap.length > 0 ? [3, 'overlap'] : bothHot ? [3, 'hot'] : sameCheckout ? [2, 'checkout'] : [1, 'repo']
125 return { level, kind, overlap }
126}
127
128/**
129 * How two sessions clash. Every repo they both work in is judged on its own (same files 3, both on single-owner
130 * files 3, same working copy 2, else 1) and the peer raises one alert, for its strongest repo.
131 */
132export function assess(me: Presence, peer: Presence, now: number): Alert | null {
133 if (peer.sessionId === me.sessionId || !isLive(peer, now)) return null
134
135 const theirs = new Map(activeRepos(peer, now).map(r => [r.repoKey, r]))
136 let best: { repo: RepoEntry; found: Pick<Alert, 'level' | 'kind' | 'overlap'> } | null = null
137 for (const repo of activeRepos(me, now)) {
138 const other = theirs.get(repo.repoKey)
139 if (!other) continue
140 const found = assessRepo(repo, other, now)
141 const wins =
142 !best ||
143 found.level > best.found.level ||
144 (found.level === best.found.level && found.overlap.length > best.found.overlap.length) ||
145 (found.level === best.found.level && found.overlap.length === best.found.overlap.length && repo.repoKey === me.home)
146 if (wins) best = { repo, found }
147 }
148 if (!best) return null
149 // A session nobody has touched for hours only speaks up when the files already clash.
150 if (isIdle(peer, now) && best.found.level < 3) return null
151
152 const peerRepo = theirs.get(best.repo.repoKey)!
153 return {
154 sessionId: peer.sessionId,
155 ...best.found,
156 repoKey: best.repo.repoKey,
157 repoName: best.repo.repoName,
158 title: peer.title ?? '',
159 hostId: isHostId(peer.hostId) ? peer.hostId : '',
160 task: peer.task,
161 branch: peerRepo.branch,
162 }
163}
164
165export function assessAll(me: Presence, peers: Presence[], now: number): Alert[] {
166 return peers
167 .map(p => assess(me, p, now))
168 .filter((a): a is Alert => a !== null)
169 .sort((a, b) => b.level - a.level)
170}
171
172// Paths as git spells them: forward slashes, so Windows tool paths compare with `git rev-parse` output.
173export const slashes = (path: string) => path.replace(/\\/g, '/')
174
175/** The file relative to the repo's top level, or null when it lies outside it. */
176export function repoRelative(toplevel: string, path: string): string | null {
177 const root = slashes(toplevel).replace(/\/+$/, '') + '/'
178 const file = slashes(path)
179 return file.startsWith(root) ? file.slice(root.length) : null
180}
181
182export const reasonOf = (w: Words, a: Alert) => w.reason(a.kind, a.overlap.length)
183
184const list = (w: Words, xs: string[], max = 8) =>
185 xs.length === 0 ? w.none : xs.slice(0, max).join(', ') + (xs.length > max ? ` (+${xs.length - max})` : '')
186
187const quote = (w: Words, s: string) => (s ? `«${s}»` : w.noTask)
188
189/** How a session is named to the person: its sidebar title, else the start of what it was asked. */
190export const nameOf = (w: Words, p: { title?: string; task: string }) => (p.title ? `«${p.title}»` : quote(w, p.task))
191
192/** A desktop session id as the app spells it; anything else (from a peer's file) is never opened. */
193export const isHostId = (id: unknown): id is string => typeof id === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(id)
194
195/** The desktop app's own link to a session, as its get_session answers it. */
196export const sessionLink = (hostId: string) => `claude://claude.ai/epitaxy/${hostId}`
197
198/** The repo entry a message speaks about: the alert's repo, else the session's home. */
199const entryFor = (p: Presence, repoKey: string): RepoEntry => p.repos[repoKey] ?? p.repos[p.home]!
200
201// What each peer session receives. `huddle` is the huddle it joins: with none, this session leads a new one.
202export function peerMessage(
203 w: Words,
204 me: Presence,
205 alert: Alert,
206 now: number,
207 huddle?: { id: string; leaderId: string },
208): string {
209 const repo = entryFor(me, alert.repoKey)
210 return w.peerMessage({
211 repo: alert.repoName,
212 level: alert.level,
213 reason: reasonOf(w, alert),
214 id: me.sessionId,
215 task: nameOf(w, me),
216 branch: repo.branch,
217 path: repo.toplevel,
218 files: list(w, recent(repo.files, now)),
219 clash: alert.overlap.length ? list(w, alert.overlap) : null,
220 leader: huddle?.leaderId ?? me.sessionId,
221 marker: huddle ? huddleMarker(huddle.id, huddle.leaderId) : '',
222 })
223}
224
225// What the initiating session's own model is asked to do.
226export function leadPrompt(w: Words, me: Presence, alerts: Alert[], sent: string[]): string {
227 const repos = [...new Set(alerts.map(a => a.repoName))].join(', ')
228 return [
229 w.leadIntro(repos),
230 ...alerts.map(a =>
231 w.leadRow({
232 id: a.sessionId,
233 level: a.level,
234 reason: reasonOf(w, a),
235 task: nameOf(w, a),
236 branch: a.branch,
237 clash: a.overlap.length ? list(w, a.overlap) : null,
238 failed: !sent.includes(a.sessionId),
239 }),
240 ),
241 w.leadClose,
242 ].join('\n')
243}
244
245export function sameJson(a: unknown, b: unknown): boolean {
246 return JSON.stringify(a) === JSON.stringify(b)
247}
248
249export const sameAlerts = (a: Alert[], b: Alert[]): boolean => sameJson(a, b)
250
251// ---- Huddle: the marker line every protocol message carries, so the receiving side knows it joined. ----
252
253const ID = '[A-Za-z0-9_-]{1,80}'
254const MARKER = new RegExp(`\\[huddle:(${ID}) leader:(${ID})\\]`)
255const ADD_MARKER = new RegExp(`\\[huddle-add:(${ID}) ((?:${ID})(?:,${ID})*)\\]`)
256
257export const huddleMarker = (id: string, leaderId: string) => `[huddle:${id} leader:${leaderId}]`
258export const addMarker = (id: string, sessionIds: string[]) => `[huddle-add:${id} ${sessionIds.join(',')}]`
259
260export function parseHuddleMarker(text: string): { id: string; leaderId: string } | null {
261 const m = MARKER.exec(text)
262 return m ? { id: m[1]!, leaderId: m[2]! } : null
263}
264
265export function parseAddMarker(text: string): { id: string; sessionIds: string[] } | null {
266 const m = ADD_MARKER.exec(text)
267 return m ? { id: m[1]!, sessionIds: m[2]!.split(',') } : null
268}
269
270/** The message without its machine lines, for the short copy a busy agent reads mid-turn. */
271export const stripMarkers = (text: string): string =>
272 text
273 .split('\n')
274 .filter(line => !MARKER.test(line) && !ADD_MARKER.test(line))
275 .join('\n')
276 .trim()
277
278export const newHuddleId = (sessionId: string, now: number) => `${sessionId.slice(0, 8)}-${now}`
279
280export const BACKGROUND_PREFIX = 'Background (from the session that started this one)'
281
282/** What the session is "about": the start of what the person asked, never the text a parent session wrote to it. */
283export function taskFromPrompt(text: string): string {
284 const t = text.replace(/\s+/g, ' ').trim()
285 return t.startsWith(BACKGROUND_PREFIX) ? '' : t.slice(0, 140)
286}
287
288/** The sidebar title is read on every tick for the first 8, then every 4th. */
289export const shouldRefreshTitle = (tick: number): boolean => tick < 8 || tick % 4 === 0
290
291// ---- Shell edits: which directories a command enters, and what changed in a repo while it ran. ----
292
293type Word = { text: string; dynamic: boolean }
294
295/** A shell line as lists of words, one list per simple command. Quotes and `~`/`$HOME` are resolved; a word that
296 * needs the shell to expand (`$X`, backticks, globs) is flagged dynamic. */
297function splitCommand(command: string, home: string): Word[][] {
298 const cmds: Word[][] = [[]]
299 let cur = ''
300 let has = false
301 let dynamic = false
302 let quote = ''
303 const push = () => {
304 if (has) cmds[cmds.length - 1]!.push({ text: cur, dynamic })
305 cur = ''
306 has = false
307 dynamic = false
308 }
309 const cut = () => {
310 push()
311 if (cmds[cmds.length - 1]!.length) cmds.push([])
312 }
313 const homeAt = (i: number): number => {
314 for (const name of ['HOME', '{HOME}']) {
315 const end = i + 1 + name.length
316 if (command.startsWith(name, i + 1) && (name.startsWith('{') || !/[A-Za-z0-9_]/.test(command[end] ?? ''))) return end
317 }
318 return -1
319 }
320 for (let i = 0; i < command.length; i++) {
321 const c = command[i]!
322 if (quote === "'") {
323 if (c === "'") quote = ''
324 else cur += c
325 continue
326 }
327 if (c === '$') {
328 const end = home ? homeAt(i) : -1
329 if (end > 0) {
330 cur += home
331 has = true
332 i = end - 1
333 } else {
334 dynamic = true
335 has = true
336 cur += c
337 }
338 continue
339 }
340 if (quote === '"') {
341 if (c === '"') quote = ''
342 else if (c === '`') dynamic = true
343 else if (c === '\\' && '"\\$`'.includes(command[i + 1] ?? ' ')) cur += command[++i]
344 else cur += c
345 continue
346 }
347 if (c === "'" || c === '"') {
348 quote = c
349 has = true
350 } else if (c === '\\') {
351 if (command[i + 1] === '\n') i++
352 else if (i + 1 < command.length) {
353 cur += command[++i]
354 has = true
355 }
356 } else if (c === '#' && !has) {
357 while (i + 1 < command.length && command[i + 1] !== '\n') i++
358 } else if (c === '~' && !has && home && (i + 1 >= command.length || /[/\s;&|)]/.test(command[i + 1]!))) {
359 cur += home
360 has = true
361 } else if (c === '\n' || c === ';' || c === '|' || c === '&' || c === '(' || c === ')') cut()
362 else if (c === ' ' || c === '\t' || c === '\r') push()
363 else {
364 if (c === '*' || c === '?' || c === '[' || c === '`') dynamic = true
365 cur += c
366 has = true
367 }
368 }
369 push()
370 return cmds.filter(c => c.length)
371}
372
373/** `p` against `base`, `.` and `..` folded, forward slashes: no file system involved. */
374export function resolvePath(base: string, p: string): string {
375 const abs = p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)
376 const full = slashes(abs || !base ? p : `${base}/${p}`)
377 const prefix = /^([A-Za-z]:)?\//.exec(full)?.[0] ?? ''
378 const stack: string[] = []
379 for (const part of full.slice(prefix.length).split('/')) {
380 if (part === '' || part === '.') continue
381 if (part === '..') stack.pop()
382 else stack.push(part)
383 }
384 return prefix + stack.join('/')
385}
386
387/** The directories a command names with `cd`, `pushd` and `git -C`, as absolute paths, in order. A `cd` moves
388 * the directory the later parts of the line are read against. */
389export function dirsInCommand(command: string, cwd: string, home = ''): string[] {
390 const out: string[] = []
391 const add = (d: string) => {
392 if (d && !out.includes(d)) out.push(d)
393 }
394 let running = slashes(cwd)
395 for (const words of splitCommand(command, slashes(home))) {
396 let i = 0
397 while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!.text)) i++
398 const head = words[i]?.text
399 if (head === 'cd' || head === 'pushd') {
400 const arg = words.slice(i + 1).find(x => !x.text.startsWith('-'))
401 if (arg && !arg.dynamic) {
402 running = resolvePath(running, arg.text)
403 add(running)
404 }
405 } else if (head === 'git') {
406 let base = running
407 for (let j = i + 1; j < words.length; j++) {
408 const t = words[j]!
409 if (t.text === '-C') {
410 const dir = words[++j]
411 if (dir && !dir.dynamic) {
412 base = resolvePath(base, dir.text)
413 add(base)
414 }
415 } else if (t.text === '-c') j++
416 else if (!t.text.startsWith('-')) break
417 }
418 }
419 }
420 return out
421}
422
423/** `git status --porcelain=v1 -z` as path -> two-letter status. A rename lists its old path after the new one. */
424export function parsePorcelain(out: string, cap = 2000): Record<string, string> {
425 const map: Record<string, string> = {}
426 const parts = out.split('\0')
427 let count = 0
428 for (let i = 0; i < parts.length && count < cap; i++) {
429 const entry = parts[i]!
430 if (entry.length < 4) continue
431 const xy = entry.slice(0, 2)
432 if (xy.includes('R') || xy.includes('C')) i++
433 map[entry.slice(3)] = xy
434 count++
435 }
436 return map
437}
438
439/** One repo's dirty files: git's status, and for some of them a signature of mtime and size. */
440export type Snapshot = Record<string, { status: string; sig?: string }>
441
442/** The paths that changed between two snapshots: a new status, a new signature, or not there before. A path that
443 * left the list (committed, reverted) is not an edit. */
444export function diffSnapshots(before: Snapshot, after: Snapshot): string[] {
445 const changed: string[] = []
446 for (const [path, a] of Object.entries(after)) {
447 const b = before[path]
448 if (!b || b.status !== a.status || (b.sig !== undefined && a.sig !== undefined && b.sig !== a.sig)) changed.push(path)
449 }
450 return changed.sort()
451}
452
453// ---- Huddle: who is in it, how the band reads it, who owns what. ----
454
455export const ownerKey = (repoKey: string, rel: string) => `${repoKey}::${rel}`
456
457/** The session the agreement gave this file to, if any. */
458export const ownerOf = (lead: HuddleLead | undefined, repoKey: string, rel: string): string | undefined =>
459 lead?.owners[ownerKey(repoKey, rel)]
460
461/** The leader's presence: this session's own when it leads, else the peer's file. */
462export function leaderOf(me: Presence, peers: Presence[]): Presence | null {
463 const h = me.huddle
464 if (!h) return null
465 return h.leaderId === me.sessionId ? me : (peers.find(p => p.sessionId === h.leaderId) ?? null)
466}
467
468/** My huddle while it is live: its leader's presence is live, or another member was active in the last 4 h. */
469export function liveHuddle(me: Presence, peers: Presence[], now: number): HuddleInfo | null {
470 const h = me.huddle
471 if (!h) return null
472 if (h.leaderId === me.sessionId) return h
473 const leader = peers.find(p => p.sessionId === h.leaderId)
474 if (leader && isLive(leader, now)) return h
475 const another = peers.some(
476 p => p.sessionId !== me.sessionId && p.huddle?.id === h.id && !p.ended && now - p.lastActiveAt < FILE_WINDOW_MS,
477 )
478 return another ? h : null
479}
480
481/** Every session id in my live huddle (mine and the leader's included): who joined, plus whom the leader invited. */
482export function huddleMembers(me: Presence, peers: Presence[], now: number): Set<string> | null {
483 const h = liveHuddle(me, peers, now)
484 if (!h) return null
485 const ids = new Set<string>([me.sessionId, h.leaderId])
486 for (const id of leaderOf(me, peers)?.huddle?.lead?.members ?? []) ids.add(id)
487 for (const p of peers) if (p.huddle?.id === h.id) ids.add(p.sessionId)
488 return ids
489}
490
491const who = (p: Presence): Who => ({
492 title: p.title ?? '',
493 task: p.task.slice(0, 60),
494 hostId: isHostId(p.hostId) ? p.hostId : '',
495})
496
497/** Files a member edited after the agreement that the agreement gave to another member. */
498export function ownerConflicts(lead: HuddleLead | undefined, members: Presence[], now: number): OwnerConflict[] {
499 if (!lead?.agreedAt) return []
500 const byId = new Map(members.map(m => [m.sessionId, m]))
501 const found: { at: number; c: OwnerConflict }[] = []
502 for (const editor of members) {
503 for (const repo of Object.values(editor.repos)) {
504 for (const [file, at] of Object.entries(repo.files)) {
505 if (at <= lead.agreedAt! || now - at >= FILE_WINDOW_MS) continue
506 const owner = byId.get(ownerOf(lead, repo.repoKey, file) ?? '')
507 if (owner && owner.sessionId !== editor.sessionId) {
508 found.push({ at, c: { editor: who(editor), owner: who(owner), repoName: repo.repoName, file } })
509 }
510 }
511 }
512 }
513 return found.sort((a, b) => b.at - a.at).map(f => f.c)
514}
515
516/**
517 * What the band shows. Peers that are live, not idle (unless their files clash) and share a repo are the ones to
518 * speak of: if all of them are in my huddle it is one calm line, else the alert rows for those who are not.
519 */
520export function bandState(me: Presence, peers: Presence[], now: number): BandState {
521 const alerts = assessAll(me, peers, now)
522 if (alerts.length === 0) return { kind: 'none' }
523 const members = huddleMembers(me, peers, now)
524 const outsiders = alerts.filter(a => !members?.has(a.sessionId))
525 if (outsiders.length > 0) return { kind: 'alert', alerts: outsiders, hasHuddle: members !== null }
526
527 const inAlert = new Set(alerts.map(a => a.sessionId))
528 const others = peers.filter(p => members!.has(p.sessionId) && isLive(p, now) && (!isIdle(p, now) || inAlert.has(p.sessionId)))
529 const h = me.huddle!
530 const leader = leaderOf(me, peers)
531 const confirmed =
532 (h.leaderId === me.sessionId || h.ackAt ? 1 : 0) + others.filter(p => p.sessionId === h.leaderId || p.huddle?.ackAt).length
533 return {
534 kind: 'huddle',
535 size: others.length + 1,
536 confirmed,
537 leader: leader ? who(leader) : { title: '', task: '', hostId: '' },
538 leaderIsMe: h.leaderId === me.sessionId,
539 conflicts: ownerConflicts(leader?.huddle?.lead, [me, ...others], now),
540 }
541}
542
543/** A repo's short name: the last part of its remote, or, with no remote (the key is its git directory), its folder. */
544export function repoNameOf(repoKey: string, toplevel: string): string {
545 const fromKey = repoKey.split('/').filter(Boolean).at(-1)?.replace(/\.git$/, '') ?? ''
546 return fromKey || slashes(toplevel).split('/').filter(Boolean).at(-1) || toplevel
547}
548
549/** A huddle message cut to what a busy agent needs now: who, what clashes and how to answer. The protocol's later
550 * points and the machine lines are left for the full message, which still arrives when the turn ends. */
551export function compactForMidTurn(text: string, max = 1500): string {
552 const lines = stripMarkers(text)
553 .split('\n')
554 .filter(line => !/^\s*[2-9]\.\s/.test(line))
555 const out = lines.join('\n').trim()
556 return out.length > max ? `${out.slice(0, max - 1)}…` : out
557}
558types/index.d.ts 46 lines1export type Level = 1 | 2 | 3
2
3/** Why a peer raised its level: same files, both on single-owner files, same working copy, or just the same repo. */
4export type AlertKind = 'overlap' | 'hot' | 'checkout' | 'repo'
5
6export type Alert = {
7 sessionId: string
8 level: Level
9 kind: AlertKind
10 /** The repo the alert is about: the strongest one the two sessions share. */
11 repoKey: string
12 repoName: string
13 /** The name the person sees for that session (its sidebar title), when it has one. */
14 title: string
15 /** The desktop app's id for that session, so the band can open it; empty outside the app. */
16 hostId: string
17 task: string
18 branch: string
19 overlap: string[]
20}
21
22/** How a session is named to the person, and how the band opens it. */
23export type Who = { title: string; task: string; hostId: string }
24
25/** A member of the huddle edited a file the agreement gave to another member. */
26export type OwnerConflict = { editor: Who; owner: Who; repoName: string; file: string }
27
28/** What the band shows: nothing, the peers that are not in the huddle, or a calm line when everyone is. */
29export type BandState =
30 | { kind: 'none' }
31 | { kind: 'alert'; alerts: Alert[]; hasHuddle: boolean }
32 | { kind: 'huddle'; size: number; confirmed: number; leader: Who; leaderIsMe: boolean; conflicts: OwnerConflict[] }
33
34export type CoordResult = { at: number; sent: number; failed: string[] }
35
36declare module 'claude-code' {
37 interface PluginState {
38 huddle: {
39 alerts: Alert[]
40 band: BandState
41 snoozeUntil: number
42 lastCoord: CoordResult | null
43 }
44 }
45}
46