SLOPSHOPPER

huddle

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.

newbandguardcommandtoaststatus
v0.3.0MITupdated 2026-10-15Br4inergy/claude-huddle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · huddle
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ huddle │ ⏺ Read(src/auth.ts) │ No other agents are active in this repo │ ⎿ Read 6 lines │ anymore. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /huddle ⎿ huddle: No other agents are active in this repo anymore. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Huddle

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.

The problem

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:

  • Two agents edit the same file and one silently overwrites the other.
  • Both bump package.json or the lockfile, or both write a migration, and the merge is a mess.
  • Two sessions share one working copy and one runs git stash or checkout under the other's feet.
  • You only find out at merge time, when it's expensive.

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.

What it looks like

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:

  1. Every other live session in the shared repos receives a short coordination protocol: who's asking, what they're doing, which files clash, and who leads.
  2. Your own session leads: it waits for their replies, then records one owner per disputed file and a 3-line summary with the agree tool, which reaches everyone. It gives you the summary.
  3. The other sessions answer the leader and keep working on what doesn't clash.

Once a huddle is going, the band changes with it:

  • Everyone who shares a repo with you is in it: one calm line instead of alerts, ✓ 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»).
  • A new session shows up, or one that isn't in it: the usual alert rows for those sessions only, and the button reads Add to huddle. It sends the protocol only to them (a member that adds tells the leader, who takes the replies).

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.

Install

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

Levels

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.

LevelWhenWhat you see
1 · infoAnother session in the same repo, in another worktree or clone, on different filesGrey line, status bar count
2 · attentionAnother session in the same working copy (shared index, stash, installs)Yellow line
3 · clashBoth 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.

The protocol the agents follow

  • Reply to the leader (the protocol names its session id) with the send tool or SendMessage: their task, the files they edited and the files they plan to touch.
  • One owner per disputed file. Whoever goes first finishes and commits first; the other updates before touching those files. Lockfiles, package.json, migrations, schema and generated files get a single owner.
  • In a shared working copy, nobody runs checkout, stash, reset or installs dependencies without telling the other, and each commits only its own files with a pathspec (git commit -- <files>).
  • Each agent keeps to its original instruction. If committing, pushing or publishing was already part of its task, it still does it, in the agreed order and carefully. If it wasn't, coordinating is no reason to start. Coordinating is never a reason to stop doing what it was asked.
  • The leader closes with the agree tool, once: one owner per disputed file and a short summary, sent to every member.

Tools the agents get

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

What it doesn't do (yet)

  • It only sees Claude Code sessions with Huddle installed. Codex, other tools or a human editing by hand are invisible to it.
  • Shell edits are inferred from 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.
  • It doesn't lock files or block edits. It warns and gets the agents talking.
  • Sessions on different machines don't see each other. The registry is local to your computer.
  • An agent that is busy and makes no tool call gets a huddle message at the end of its turn, like any other message: the mid-turn copy and the owner warnings ride on tool calls.
  • Jumping to a session by clicking its name works in the Claude desktop app. In a terminal, and for a headless claude -p run started from a desktop session, the name is shown, not linked.
  • In the desktop app, a session reads its own sidebar title only where that read needs no permission prompt; otherwise the title arrives with your next prompt.

Files

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.

Language

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.

Development

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.

License

MIT © Br4inergy

Source 4 files
hooks/register.tsx 853 lines
1import { 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}
853
hooks/i18n.ts 236 lines
1/**
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}
236
hooks/logic.ts 558 lines
1import 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}
558
types/index.d.ts 46 lines
1export 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