hivecode-mcp 0.12.1 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hive-branch.js ADDED
@@ -0,0 +1,656 @@
1
+ // hive-branch -- a Hivecode room bound to a git branch.
2
+ //
3
+ // npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
4
+ // npx -y hivecode-mcp@latest branch <name> --join "<link>" (a teammate, in their own clone)
5
+ // npx -y hivecode-mcp@latest branch <name> --reseed (the relay lost the room)
6
+ // npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <b>] [--force "<reason>"]
7
+ //
8
+ // Agents join it with hive_join { branch: "<name>" } from the repo (or { branch, repo }).
9
+ //
10
+ // WHY. Developers already know branches. "Everyone on fix-x, people and agents, in one live
11
+ // workspace" needs no new words, and the work arrives as an ordinary PR on fix-x, so a team does
12
+ // not adopt anything to receive it. Git stays the record; the room is where the work happens.
13
+ //
14
+ // THE FOUR RULES, each a way this hurts somebody when it is wrong:
15
+ // 1. THE ROOM ID IS RANDOM. A room named after its branch would put every repo's
16
+ // `feature/login` into one room, and on an open relay anyone could guess it and read the code.
17
+ // 2. A ROOM FOLDER IS NEVER A GIT CHECKOUT. Each holds a `.git` FILE naming a git directory that
18
+ // does not exist, so git refuses to run inside it: no checkout, pull, rebase, stash, reset or
19
+ // clean run there can rewrite the room's files. Git in the developer's own repo never reaches
20
+ // the room either, because nobody works in that checkout. A branch that moved underneath the
21
+ // room is caught when the work lands (hive-pr's staleness check, then a compare-and-swap).
22
+ // 3. ONE FOLDER PER PARTICIPANT, ~/hivecode/<room>/<name>. Two participants in one folder cannot
23
+ // be told apart, and claims and per-agent commits both depend on telling them apart.
24
+ // 4. NOBODY COMMITS INSIDE THE ROOM. Work leaves once, through `pr`, which builds the commits in a
25
+ // temporary worktree so the developer's own checkout is never switched or written.
26
+ //
27
+ // Error codes Q560-Q574 are this module's: Q560-Q569 binding and seeding, Q570-Q574 landing.
28
+
29
+ import fs from 'fs'
30
+ import os from 'os'
31
+ import path from 'path'
32
+ import crypto from 'crypto'
33
+ import { spawnSync } from 'child_process'
34
+ import { fileURLToPath } from 'url'
35
+ import ignore from 'ignore'
36
+ import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
37
+ import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
38
+ import { findSecrets } from './hive-secrets.js'
39
+ import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
40
+ import { isSafeRelPath } from './token.js'
41
+
42
+ const TEXT_MAX = 1_000_000 // sync.js MAX_BYTES: a larger file never crosses the relay
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
44
+ const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
45
+
46
+ // ---- where things live --------------------------------------------------------------------
47
+
48
+ // Read at call time, not import time, so a caller (or a test) that sets HIVE_HOME is honoured.
49
+ export const hiveHome = () => process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
50
+ // The same folder rule as hive-mcp.js, so a branch room's folders sit beside every other room's.
51
+ function safeSegment(s) {
52
+ const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
53
+ return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
54
+ }
55
+ export function roomFolder(link, name) {
56
+ return path.join(hiveHome(), safeSegment(parseLink(link).room), safeSegment(name))
57
+ }
58
+
59
+ // ---- git ----------------------------------------------------------------------------------
60
+
61
+ function git(cwd, args, { input, raw = false, trim = true } = {}) {
62
+ const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
63
+ if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
64
+ let out = r.stdout == null ? (raw ? Buffer.alloc(0) : '') : r.stdout
65
+ if (!raw && trim) out = out.replace(/\s+$/, '')
66
+ return { code: r.status, out, err: String(r.stderr || '').trim() }
67
+ }
68
+ const revParse = (root, ref) => { const r = git(root, ['rev-parse', '--verify', '--quiet', ref]); return r.code === 0 ? r.out : '' }
69
+
70
+ /** The top of the git working tree containing `dir`, or null. A room folder answers null: git refuses there. */
71
+ export function repoRoot(dir) {
72
+ if (!dir || !fs.existsSync(dir)) return null
73
+ const r = git(dir, ['rev-parse', '--show-toplevel'])
74
+ return r.code === 0 && r.out ? path.resolve(r.out) : null
75
+ }
76
+
77
+ const sameDir = (a, b) => {
78
+ const n = (p) => path.resolve(p).replace(/[\\/]+$/, '')
79
+ return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
80
+ }
81
+
82
+ /**
83
+ * The repository a branch room is for. NOT THE HOME DIRECTORY: a folder that is not a repository of
84
+ * its own sits inside an enclosing one if there is one, and a home directory kept under git is the
85
+ * usual enclosing one -- so "the repo" silently becomes $HOME and its committed files would be
86
+ * published to a room. Measured on the development machine, whose home directory is a git repo: a
87
+ * test's "outside any repo" folder under the temp directory bound, branched and seeded the home repo.
88
+ */
89
+ function projectRoot(cwd) {
90
+ const root = repoRoot(cwd)
91
+ if (!root) throw coded('Q560', `${cwd} is not inside a git repository. Run this in the repo whose branch the room is for.`)
92
+ if (sameDir(root, os.homedir())) throw coded('Q560', `the git repository around ${cwd} is your home directory (${root}), which is not a project: ${cwd} is not a repository of its own. Run this in the project's own repository.`)
93
+ return root
94
+ }
95
+
96
+ function validBranch(root, name) {
97
+ // A leading '-' would be read as an option by every git command it is later passed to.
98
+ const r = !name || name.startsWith('-') ? { code: 1 } : git(root, ['check-ref-format', '--branch', name])
99
+ if (r.code !== 0) throw coded('Q561', `"${name}" is not a valid branch name.`)
100
+ return r.out
101
+ }
102
+
103
+ // The binding lives in the repo's own git config (shared by all its worktrees, never committed,
104
+ // never pushed), under hivecode.<branch>.*: room = the join link, from = the branch a PR goes
105
+ // into, base = the commit the room was seeded from, seed = the developer's own room folder.
106
+ const KEY = (branch, k) => `hivecode.${branch}.${k}`
107
+ export function readBinding(root, branch) {
108
+ const get = (k) => { const r = git(root, ['config', '--local', '--get', KEY(branch, k)]); return r.code === 0 ? r.out : '' }
109
+ const link = get('room')
110
+ return link ? { link, from: get('from'), base: get('base'), seed: get('seed'), files: get('files') } : null
111
+ }
112
+ function writeBinding(root, branch, b) {
113
+ for (const [k, v] of Object.entries(b)) {
114
+ if (!v) continue
115
+ const r = git(root, ['config', '--local', KEY(branch, k), v])
116
+ if (r.code !== 0) throw coded('Q569', `could not record the room in git config: ${r.err}`)
117
+ }
118
+ }
119
+
120
+ /** Every regular file in `commit`, with its bytes. Symlinks and submodules are not files a room holds. */
121
+ export function treeFiles(root, commit) {
122
+ const ls = git(root, ['ls-tree', '-r', '-z', '--full-tree', commit], { trim: false })
123
+ if (ls.code !== 0) throw coded('Q563', `could not read the files of ${commit}: ${ls.err}`)
124
+ const entries = []
125
+ for (const rec of ls.out.split('\0')) {
126
+ const tab = rec.indexOf('\t')
127
+ if (tab < 0) continue
128
+ const [mode, type, sha] = rec.slice(0, tab).split(' ')
129
+ const rel = rec.slice(tab + 1)
130
+ if (type !== 'blob' || (mode !== '100644' && mode !== '100755') || !isSafeRelPath(rel)) continue
131
+ entries.push({ rel, mode, sha })
132
+ }
133
+ const blobs = readBlobs(root, [...new Set(entries.map((e) => e.sha))])
134
+ return entries.filter((e) => blobs.has(e.sha)).map((e) => ({ ...e, buf: blobs.get(e.sha) }))
135
+ }
136
+
137
+ /** `git cat-file --batch`, parsed: sha -> Buffer, exactly the bytes git stored (no smudge, no CRLF). */
138
+ function readBlobs(root, shas) {
139
+ const out = new Map()
140
+ if (!shas.length) return out
141
+ const r = git(root, ['cat-file', '--batch'], { input: shas.join('\n') + '\n', raw: true })
142
+ if (r.code !== 0) throw coded('Q563', `could not read file contents from git: ${r.err}`)
143
+ const buf = r.out
144
+ let i = 0
145
+ while (i < buf.length) {
146
+ const nl = buf.indexOf(10, i)
147
+ if (nl < 0) break
148
+ const [sha, type, size] = buf.subarray(i, nl).toString('utf8').split(' ')
149
+ if (type === 'missing' || size === undefined) { i = nl + 1; continue }
150
+ const n = Number(size)
151
+ out.set(sha, buf.subarray(nl + 1, nl + 1 + n))
152
+ i = nl + 1 + n + 1
153
+ }
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * Would a sync client publish this file? The same tests the sync applies -- text, at most 1 MB,
159
+ * not always-ignored, not ignored by a .gitignore in the tree, not one of Hivecode's own files --
160
+ * so the seed can say exactly what the room will hold, and a joiner can be given the rest from git.
161
+ */
162
+ export function roomCarries(files) {
163
+ const ig = ignore().add(ALWAYS_IGNORE)
164
+ const rootIgnore = files.find((f) => f.rel === '.gitignore')
165
+ if (rootIgnore) ig.add(rootIgnore.buf.toString('utf8'))
166
+ const nested = files.filter((f) => f.rel.endsWith('/.gitignore'))
167
+ .map((f) => ({ prefix: f.rel.slice(0, -'.gitignore'.length), ig: ignore().add(f.buf.toString('utf8')) }))
168
+ const ignored = (rel) => {
169
+ if (ig.ignores(rel)) return true
170
+ for (const n of nested) {
171
+ if (!rel.startsWith(n.prefix)) continue
172
+ const sub = rel.slice(n.prefix.length)
173
+ if (sub && n.ig.ignores(sub)) return true
174
+ }
175
+ return false
176
+ }
177
+ // A file holding a key is held at home by every client (sync.js heldForSecret), so a room that
178
+ // promised to carry it would never finish seeding (measured 3 Oct 2026: Q565, "4 of 5 files",
179
+ // nothing bound). It is not carried, and each participant gets it from git like a binary.
180
+ const holdsKey = (f) => process.env.HIVE_SHARE_SECRETS !== '1' && findSecrets(f.buf.toString('utf8')).length > 0
181
+ return (f) => f.buf.length <= TEXT_MAX && !f.buf.includes(0) && !SKIP.has(f.rel) && !ignored(f.rel) && !holdsKey(f)
182
+ }
183
+
184
+ // Hivecode's own bookkeeping (.hive.json, .hive-shadow.json, .hive-lock.json, ...). If a repo happens
185
+ // to track one, it is never written into a room folder: a foreign .hive-lock.json would make the
186
+ // folder look held by some other process, and a foreign shadow is somebody else's ancestry.
187
+ // By exact name: a project's own `.hiverc` is its business. All of these are already never carried
188
+ // (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
189
+ const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json'])
190
+ const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
191
+
192
+ function writeFiles(dir, files) {
193
+ for (const f of files) {
194
+ if (isOwnFile(f.rel)) continue
195
+ const full = path.join(dir, ...f.rel.split('/'))
196
+ fs.mkdirSync(path.dirname(full), { recursive: true })
197
+ fs.writeFileSync(full, f.buf)
198
+ if (f.mode === '100755' && process.platform !== 'win32') { try { fs.chmodSync(full, 0o755) } catch { /* best effort */ } }
199
+ }
200
+ }
201
+
202
+ // ---- rule 2: a room folder is never a git checkout ------------------------------------------
203
+
204
+ // One line, because git reads a `.git` file's first line as the path to the repository. The path
205
+ // does not exist, so every git command run in the folder stops with "not a git repository" and the
206
+ // path it prints says why. An EMPTY `.git` directory would not do this: git skips an invalid one
207
+ // and walks on up to the nearest real repository (measured: `git stash -u` run inside the folder
208
+ // then stashed the room's files away through the parent repo, which deletes them).
209
+ export const NOT_A_CHECKOUT = 'gitdir: ./.hivecode-live-room-not-a-git-checkout\n'
210
+
211
+ export function markNotACheckout(dir) {
212
+ fs.mkdirSync(dir, { recursive: true })
213
+ const p = path.join(dir, '.git')
214
+ let st = null
215
+ try { st = fs.lstatSync(p) } catch { /* absent: ours to create */ }
216
+ if (st) {
217
+ if (st.isFile() && fs.readFileSync(p, 'utf8') === NOT_A_CHECKOUT) return { ok: true }
218
+ return { ok: false, why: `${dir} is a git checkout (it has its own .git). A branch room's folders never are: git run there could rewrite the room's files. Leave \`dir\` out and the folder is chosen for you.` }
219
+ }
220
+ fs.writeFileSync(p, NOT_A_CHECKOUT)
221
+ return { ok: true }
222
+ }
223
+
224
+ /**
225
+ * The files the room cannot carry (binary, over 1 MB, always-ignored), written into a joiner's
226
+ * folder from the branch. ONLY those: a file the room does carry but that is missing here was
227
+ * deleted in the room, and writing it back would publish the deletion's undo to everyone.
228
+ */
229
+ export function fillFromGit({ root, commit, dir }) {
230
+ const files = treeFiles(root, commit)
231
+ const carries = roomCarries(files)
232
+ const missing = files.filter((f) => !carries(f) && !SKIP.has(f.rel) && !isOwnFile(f.rel) && !fs.existsSync(path.join(dir, ...f.rel.split('/'))))
233
+ writeFiles(dir, missing)
234
+ return missing.length
235
+ }
236
+
237
+ /**
238
+ * What hive_join needs for { branch }: the room link, and the commit to fill non-text files from.
239
+ * Returns { ok:false, message } with the next step instead of throwing -- an agent acts on text.
240
+ */
241
+ export function resolveForJoin({ branch, repo }) {
242
+ let root
243
+ try { root = projectRoot(repo) } catch (e) {
244
+ return { ok: false, message: `${e.message} It has no branch room "${branch}". Pass repo: "<path to the repo>" -- or join with the room's link instead.` }
245
+ }
246
+ let name
247
+ try { name = validBranch(root, branch) } catch (e) { return { ok: false, message: e.message } }
248
+ const b = readBinding(root, name)
249
+ if (!b) return { ok: false, message: `Q562 branch "${name}" has no Hivecode room in directory ${root}. If your repository is elsewhere, pass repo: "/path/to/repo" in hive_join. Otherwise create one with:\n npx -y hivecode-mcp@latest branch ${name}\nor, if a teammate already made it, records it with:\n npx -y hivecode-mcp@latest branch ${name} --join "<link>"` }
250
+ // A teammate's clone usually has the branch only as origin/<name>; that is as good a source for
251
+ // the files the room does not carry. With neither, those files are simply not filled.
252
+ const commit = revParse(root, `refs/heads/${name}`) || revParse(root, `refs/remotes/origin/${name}`) || b.base
253
+ return { ok: true, root, branch: name, link: b.link, commit }
254
+ }
255
+
256
+ // ---- seeding: the room starts as the branch ------------------------------------------------
257
+
258
+ async function waitFor(pred, ms, every = 200) {
259
+ const t0 = Date.now()
260
+ for (;;) {
261
+ let v = false
262
+ try { v = pred() } catch { /* not ready */ }
263
+ if (v) return true
264
+ if (Date.now() - t0 > ms) return false
265
+ await sleep(every)
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Publish `commit` into the (empty) room from the developer's own room folder, and report success
271
+ * only after a FRESH JOINER has received every file the room should hold, byte for byte. A seed
272
+ * that is merely "sent" is the false success this refuses: agents would build on a partial tree.
273
+ */
274
+ async function seedRoom({ root, branch, commit, link, you, log }) {
275
+ const { relay, room } = parseLink(link)
276
+ const files = treeFiles(root, commit)
277
+ const carries = roomCarries(files)
278
+ const expected = new Map(files.filter(carries).map((f) => [f.rel, f.buf.toString('utf8')]))
279
+ const dir = roomFolder(link, you)
280
+ const mark = markNotACheckout(dir)
281
+ if (!mark.ok) throw coded('Q564', mark.why)
282
+ writeFiles(dir, files)
283
+ fs.writeFileSync(path.join(dir, '.hive.json'), JSON.stringify({ relay, room }, null, 2))
284
+
285
+ // PROVENANCE OFF FOR THE SEED. The seed is the branch exactly as it already is; a receipt for it
286
+ // would name the developer as a co-author of every file an agent later changes. Agents' first
287
+ // receipts still chain from it, because a receipt's `parent` is the hash of what it built on.
288
+ const prov = process.env.HIVE_PROVENANCE
289
+ process.env.HIVE_PROVENANCE = 'off'
290
+ let seeder
291
+ try { seeder = startSync({ relay, room, dir, name: you, kind: 'human', log: () => {} }) }
292
+ finally { if (prov === undefined) delete process.env.HIVE_PROVENANCE; else process.env.HIVE_PROVENANCE = prov }
293
+
294
+ const budget = 30000 + 40 * expected.size
295
+ let verifier = null
296
+ const vdir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-seed-check-'))
297
+ try {
298
+ const up = await waitFor(() => seeder.connection().connected && [...expected].every(([r, t]) => seeder._fileText(r) === t), budget)
299
+ if (!up) {
300
+ const st = seeder.status()
301
+ throw coded('Q565', `the seed did not finish publishing: the room holds ${st.roomFiles} of ${expected.size} files after ${Math.round(budget / 1000)} s. ${st.notSyncing ? `${st.notSyncing} file(s) were refused by the relay. ` : ''}Nothing was bound; run the same command again.`)
302
+ }
303
+ verifier = startSync({ relay, room, dir: vdir, name: `${you}-check`, kind: 'ai', passive: true, log: () => {} })
304
+ const read = (r) => { try { return fs.readFileSync(path.join(vdir, ...r.split('/')), 'utf8') } catch { return null } }
305
+ const got = await waitFor(() => [...expected].every(([r, t]) => read(r) === t), budget, 300)
306
+ if (!got) {
307
+ const missing = [...expected.keys()].filter((r) => read(r) !== expected.get(r))
308
+ throw coded('Q566', `a fresh joiner did not receive the whole branch: ${missing.length} of ${expected.size} files are missing or different (first: ${missing.slice(0, 5).join(', ')}). Nothing was bound; agents would have built on a partial tree.`)
309
+ }
310
+ const short = commit.slice(0, 7)
311
+ seeder.say(`Room for git branch \`${branch}\` @ ${short}, seeded by ${you} with ${expected.size} file(s). Work here lands on \`${branch}\` as one commit per agent via \`npx -y hivecode-mcp@latest pr ${branch}\`. Room folders are not git checkouts on purpose: edit files, do not commit.`)
312
+ await sleep(600) // let the announcement leave before the socket closes
313
+ return { dir, count: expected.size, total: files.length }
314
+ } finally {
315
+ try { verifier && verifier.stop() } catch { /* going away */ }
316
+ try { seeder.stop() } catch { /* going away */ }
317
+ try { fs.rmSync(vdir, { recursive: true, force: true }) } catch { /* temp */ }
318
+ }
319
+ }
320
+
321
+ /** The paths the room lists right now, from a passive look that syncs no files (null if unreachable). */
322
+ async function roomPaths(link, you) {
323
+ const { relay, room } = parseLink(link)
324
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
325
+ const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
326
+ try {
327
+ let synced = false
328
+ peek.provider.on('sync', (s) => { if (s) synced = true })
329
+ if (!(await waitFor(() => synced, 15000))) return null
330
+ await sleep(300)
331
+ return new Set(peek.doc.getMap('manifest').keys())
332
+ } finally {
333
+ try { peek.stop() } catch { }
334
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
335
+ }
336
+ }
337
+ const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
338
+
339
+ /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
340
+ function defaultBase(root, name) {
341
+ const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
342
+ if (cur.code === 0 && cur.out && cur.out !== name) return cur.out
343
+ const o = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
344
+ const d = o.code === 0 && o.out ? o.out.replace(/^origin\//, '') : ''
345
+ return d !== name ? d : ''
346
+ }
347
+
348
+ /**
349
+ * Delete a room folder ONLY if it is one this tool made: under HIVE_HOME, carrying our `.git`
350
+ * line, and naming this room in its .hive.json. The path comes from git config, and config is
351
+ * editable text -- a path that fails any of these is refused, never removed.
352
+ */
353
+ function removeRoomFolder(dir, room) {
354
+ const abs = path.resolve(dir)
355
+ const home = path.resolve(hiveHome()) + path.sep
356
+ let ours = false
357
+ try {
358
+ ours = abs.startsWith(home) &&
359
+ fs.readFileSync(path.join(abs, '.git'), 'utf8') === NOT_A_CHECKOUT &&
360
+ JSON.parse(fs.readFileSync(path.join(abs, '.hive.json'), 'utf8')).room === room
361
+ } catch { ours = false }
362
+ if (!fs.existsSync(abs)) return
363
+ if (!ours) throw coded('Q568', `refusing to delete ${abs}: it is not a room folder this tool made for ${room}. Move it aside yourself, then run --reseed again.`)
364
+ fs.rmSync(abs, { recursive: true, force: true })
365
+ }
366
+
367
+ function youFrom(root, given) {
368
+ const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
369
+ return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
370
+ }
371
+
372
+ /**
373
+ * `branch <name>`: bind a room to a branch, creating and seeding both on first use.
374
+ * The developer's checkout is never switched or written: the branch is made with `git branch`,
375
+ * and the seed is read out of git objects, not out of the working tree.
376
+ */
377
+ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '', join = '', reseed = false, log = () => {} }) {
378
+ const root = projectRoot(cwd)
379
+ const name = validBranch(root, branch)
380
+ const me = youFrom(root, you)
381
+ const existing = readBinding(root, name)
382
+
383
+ if (join) {
384
+ const { relay: jr, room } = parseLink(join)
385
+ if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
386
+ if (existing && existing.link !== join) throw coded('Q567', `branch "${name}" is already bound to a different room here (${existing.link}). Remove it with: git config --remove-section "hivecode.${name}"`)
387
+ writeBinding(root, name, { room: join })
388
+ return { root, branch: name, link: join, you: me, joined: true }
389
+ }
390
+
391
+ if (existing && !reseed) {
392
+ const n = await roomFileCount(existing.link, me)
393
+ // Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
394
+ const lost = n === 0 && Number(existing.files || 1) > 0
395
+ return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
396
+ }
397
+
398
+ const relayUrl = currentRelayFor(relay || process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).relay
399
+ let created = false
400
+ let tip = revParse(root, `refs/heads/${name}`)
401
+ // The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
402
+ let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
403
+ if (!tip) {
404
+ const src = from || 'HEAD'
405
+ const sha = revParse(root, `${src}^{commit}`)
406
+ if (!sha) {
407
+ if (src === 'HEAD' && !revParse(root, 'HEAD')) {
408
+ throw coded('Q562', `repository "${root}" has no commits yet, so there's nothing to put in a room. Commit your files first, then run this again.`)
409
+ }
410
+ throw coded('Q562', `"${src}" is not a commit in ${root}.`)
411
+ }
412
+ const r = git(root, ['branch', '--no-track', name, sha]) // `branch`, never `checkout -b`: the checkout stays where it is
413
+ if (r.code !== 0) throw coded('Q562', `could not create branch ${name}: ${r.err}`)
414
+ tip = sha
415
+ created = true
416
+ }
417
+ if (!fromName) fromName = defaultBase(root, name)
418
+
419
+ let link = existing && existing.link
420
+ if (reseed) {
421
+ if (!existing) throw coded('Q562', `branch "${name}" has no room to reseed; run without --reseed to create one.`)
422
+ const n = await roomFileCount(existing.link, me)
423
+ if (n === null) throw coded('Q565', `could not reach the relay behind ${existing.link}.`)
424
+ if (n > 0) throw coded('Q568', `the room still holds ${n} file(s). --reseed only refills an EMPTY room (after the relay lost it); reseeding a live room would put the branch's old content over the agents' work.`)
425
+ // A fresh seed folder: the old one's record of what it last wrote belongs to the lost room.
426
+ removeRoomFolder(existing.seed || roomFolder(existing.link, me), parseLink(existing.link).room)
427
+ if (existing.seed && path.resolve(existing.seed) !== path.resolve(roomFolder(existing.link, me))) removeRoomFolder(roomFolder(existing.link, me), parseLink(existing.link).room)
428
+ } else {
429
+ link = `${relayUrl}|room-${crypto.randomBytes(13).toString('base64url')}`
430
+ }
431
+
432
+ let seeded
433
+ try {
434
+ seeded = await seedRoom({ root, branch: name, commit: tip, link, you: me, log })
435
+ } catch (e) {
436
+ // A refused bind leaves nothing behind: not a binding, not a branch nobody asked to keep, and
437
+ // not a half-seeded folder for the next attempt (which gets a new room) to trip over.
438
+ if (created && revParse(root, `refs/heads/${name}`) === tip) git(root, ['branch', '-D', name])
439
+ try { removeRoomFolder(roomFolder(link, me), parseLink(link).room) } catch { /* not ours to remove */ }
440
+ throw e
441
+ }
442
+ writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
443
+ return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
444
+ }
445
+
446
+ // ---- landing: the room's work becomes commits on the branch ---------------------------------
447
+
448
+ const HIVE_PR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hive-pr.js')
449
+ const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
450
+
451
+ /**
452
+ * THE ROOM'S DELETIONS, which hive-pr does not land: it copies the files a room holds, so a file an
453
+ * agent deleted would simply stay on the branch, in a PR that looks complete. A room bound to a
454
+ * branch starts as the whole branch, so "on the branch but no longer in the room" is a deletion --
455
+ * but only when the room provably held THAT version of the file. For each such path:
456
+ * - last changed on the branch by a landing from this room (its commit says "Room: <id>") -> delete
457
+ * - unchanged since the seed commit -> delete
458
+ * - in the seed, but changed on the branch since by someone else -> REFUSE (Q572):
459
+ * deleting it would erase a change the room never saw
460
+ * - not in the seed and not landed from this room: added to the branch outside the room -> keep
461
+ */
462
+ async function roomDeletions({ root, link, base, tip }) {
463
+ if (!base) return []
464
+ const paths = await roomPaths(link, 'hive-land')
465
+ if (!paths) throw coded('Q565', `could not reach the relay to read the room's file list, so its deletions cannot be accounted for. Nothing was changed.`)
466
+ const room = parseLink(link).room
467
+ const atTip = treeFiles(root, tip)
468
+ const carries = roomCarries(atTip)
469
+ const baseSha = new Map(treeFiles(root, base).map((f) => [f.rel, f.sha]))
470
+ const gone = [], diverged = []
471
+ for (const f of atTip) {
472
+ if (!carries(f) || paths.has(f.rel)) continue
473
+ const last = git(root, ['log', '-1', '--format=%B', tip, '--', f.rel]).out
474
+ if (new RegExp(`^Room: ${reEsc(room)}$`, 'm').test(last)) gone.push(f.rel)
475
+ else if (baseSha.get(f.rel) === f.sha) gone.push(f.rel)
476
+ else if (baseSha.has(f.rel)) diverged.push(f.rel)
477
+ }
478
+ if (diverged.length) {
479
+ const who = (rel) => git(root, ['log', '-1', '--format=%h %an %ad %s', '--date=short', tip, '--', rel]).out
480
+ throw coded('Q572', `the room deleted ${diverged.length} file(s) that were changed on the branch since it was seeded, by a change the room never saw:\n${diverged.map((r) => ` ${r} last changed by: ${who(r)}`).join('\n')}\nDeleting them would erase that change. Nothing was changed. Restore the file in the room (or delete it on the branch yourself), then run pr again.`)
481
+ }
482
+ return gone
483
+ }
484
+
485
+ function worktreesOn(root, branch) {
486
+ const r = git(root, ['worktree', 'list', '--porcelain'])
487
+ const out = []
488
+ let cur = null
489
+ for (const line of r.out.split('\n')) {
490
+ if (line.startsWith('worktree ')) cur = line.slice(9)
491
+ else if (line === `branch refs/heads/${branch}` && cur) out.push(path.resolve(cur))
492
+ }
493
+ return out
494
+ }
495
+
496
+ /**
497
+ * `pr <name>`: land the room's work on the bound branch as one commit per agent.
498
+ *
499
+ * hive-pr writes the commits (attribution and the staleness check are all its own), but it runs in
500
+ * a TEMPORARY WORKTREE detached at the branch tip, because hive-pr switches the checkout it is given
501
+ * and the developer's must never be switched. The branch then moves by compare-and-swap from the tip
502
+ * the worktree was cut from, so a commit that reached the branch meanwhile is refused, never lost.
503
+ */
504
+ export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
505
+ const root = projectRoot(cwd)
506
+ const name = validBranch(root, branch)
507
+ const b = readBinding(root, name)
508
+ if (!b) throw coded('Q562', `branch "${name}" has no Hivecode room here. Create one with: npx -y hivecode-mcp@latest branch ${name}`)
509
+ const tip = revParse(root, `refs/heads/${name}`)
510
+ if (!tip) throw coded('Q563', `branch "${name}" does not exist in ${root}. Fetch it first: git fetch origin ${name}:${name}`)
511
+
512
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-'))
513
+ const temp = `hivecode-land/${safeSegment(name)}-${process.pid}-${Date.now()}`
514
+ const cleanup = () => {
515
+ git(root, ['worktree', 'remove', '--force', tmp])
516
+ try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { }
517
+ git(root, ['worktree', 'prune'])
518
+ if (revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
519
+ }
520
+ const wt = git(root, ['worktree', 'add', '--detach', tmp, tip])
521
+ if (wt.code !== 0) { cleanup(); throw coded('Q563', `could not make a temporary worktree: ${wt.err}`) }
522
+
523
+ try {
524
+ // Accounted for BEFORE anything is written, so a refusal (Q572) leaves the branch as it was.
525
+ const deletions = await roomDeletions({ root, link: b.link, base: b.base, tip })
526
+
527
+ const args = [HIVE_PR, b.link, 'hive-pr', '--repo', tmp, '--branch', temp, '--base', 'HEAD']
528
+ if (dry) args.push('--dry-run')
529
+ if (force) args.push('--force', force)
530
+ if (only) args.push('--only', only)
531
+ const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: { ...process.env, HIVE_PROVENANCE: 'on' }, maxBuffer: 64 * 1024 * 1024, windowsHide: true })
532
+ if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
533
+ // Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
534
+ // added. For a branch room that is an answer, not a failure -- and deletions may still land.
535
+ const nothingChanged = r.status !== 0 && /^Q501 /m.test(r.stderr || '')
536
+ if (r.stderr && !nothingChanged) logErr(r.stderr.replace(/\s+$/, ''))
537
+ if (r.status !== 0 && !nothingChanged) return { ok: false, code: r.status || 1 }
538
+ if (dry) {
539
+ if (deletions.length) log(`would delete ${deletions.length} file(s) the room deleted: ${deletions.join(', ')}`)
540
+ return { ok: true, dry: true, deletions }
541
+ }
542
+
543
+ if (deletions.length) {
544
+ // hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
545
+ // commit it never made one, so make it here, at the same tip.
546
+ if (!revParse(root, `refs/heads/${temp}`)) git(tmp, ['checkout', '-q', '-b', temp])
547
+ const rm = git(tmp, ['rm', '-q', '--', ...deletions])
548
+ if (rm.code !== 0) throw coded('Q563', `could not stage the room's deletions: ${rm.err}`)
549
+ const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
550
+ const c = git(tmp, ['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>'])
551
+ if (c.code !== 0) throw coded('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
552
+ }
553
+ if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); return { ok: true, landed: 0 } }
554
+
555
+ const newTip = revParse(root, `refs/heads/${temp}`)
556
+ if (!newTip || newTip === tip) { log(`nothing to land: ${name} already has everything the room holds.`); return { ok: true, landed: 0 } }
557
+
558
+ // Move the branch. Checked out somewhere: only a clean fast-forward of that checkout, which is
559
+ // what `git pull` would do; with local changes it is refused, because moving the branch under a
560
+ // dirty checkout makes its edits look like reversals of the landed work.
561
+ const on = worktreesOn(root, name).filter((p) => p !== path.resolve(tmp))
562
+ if (on.length) {
563
+ const where = on[0]
564
+ const dirty = git(where, ['status', '--porcelain', '--untracked-files=no'])
565
+ if (dirty.out) throw coded('Q571', `${name} is checked out in ${where} with uncommitted changes. Commit or stash them there (or switch that checkout to another branch), then run pr again. Nothing was changed.`)
566
+ const ff = git(where, ['merge', '--ff-only', '--quiet', newTip])
567
+ if (ff.code !== 0) throw coded('Q570', `could not fast-forward ${name} in ${where}: ${ff.err || 'git refused'}. Nothing was changed; if the branch moved there, run pr again.`)
568
+ } else {
569
+ const cas = git(root, ['update-ref', '-m', 'hivecode: land room work', `refs/heads/${name}`, newTip, tip])
570
+ if (cas.code !== 0) throw coded('Q570', `${name} moved while this was landing (it is no longer ${tip.slice(0, 7)}). Nothing was changed; run pr again so the new commits are accounted for.`)
571
+ }
572
+ const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
573
+ log(`\nlanded on ${name}:\n${made}`)
574
+
575
+ if (push) {
576
+ const p = git(root, ['push', '-u', 'origin', name])
577
+ if (p.code !== 0) throw coded('Q573', `the work landed on ${name} locally, but the push failed: ${p.err}`)
578
+ log(`pushed ${name}`)
579
+ }
580
+ if (pr) {
581
+ const into = base || b.from
582
+ if (!into) throw coded('Q574', `the work landed on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
583
+ const body = [`Opened by Hivecode from the room bound to \`${name}\`.`, '', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.', '', '```', made, '```'].join('\n')
584
+ const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
585
+ if (g.status !== 0) throw coded('Q574', `the work landed on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
586
+ log((g.stdout || '').trim())
587
+ }
588
+ return { ok: true, landed: made.split('\n').filter(Boolean).length, tip: newTip, deletions }
589
+ } finally {
590
+ cleanup()
591
+ }
592
+ }
593
+
594
+ // ---- CLI ----------------------------------------------------------------------------------
595
+
596
+ function flags(argv) {
597
+ const pos = []
598
+ const f = {}
599
+ for (let i = 0; i < argv.length; i++) {
600
+ const a = argv[i]
601
+ if (!a.startsWith('--')) { pos.push(a); continue }
602
+ const k = a.slice(2)
603
+ if (['dry-run', 'push', 'pr', 'reseed'].includes(k)) f[k] = true
604
+ else f[k] = argv[++i] || ''
605
+ }
606
+ return { pos, f }
607
+ }
608
+
609
+ const USAGE = `usage:
610
+ npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
611
+ npx -y hivecode-mcp@latest branch <name> --join "<link>"
612
+ npx -y hivecode-mcp@latest branch <name> --reseed
613
+ npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--force "<reason>"]`
614
+
615
+ /** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
616
+ export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
617
+ const [cmd, ...rest] = argv
618
+ const { pos, f } = flags(rest)
619
+ const name = pos[0]
620
+ if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
621
+ try {
622
+ if (cmd === 'pr') {
623
+ const r = await landBranch({ cwd, branch: name, dry: !!f['dry-run'], push: !!(f.push || f.pr), pr: !!f.pr, base: f.base || '', force: f.force || '', only: f.only || '', title: f.title || '', log, logErr })
624
+ return r.ok ? 0 : r.code || 1
625
+ }
626
+ const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
627
+ const agentLine = `Use hive_join with branch "${r.branch}", repo "${r.root.replace(/\\/g, '/')}", as a name of your own (each agent picks a different one)`
628
+ if (r.joined) {
629
+ log(`Recorded: branch ${r.branch} in ${r.root} is the room ${r.link}\n\nTell each of your agents:\n ${agentLine}`)
630
+ return 0
631
+ }
632
+ if (r.existing) {
633
+ log(`Branch ${r.branch} already has a room: ${r.link}${r.roomFiles == null ? '\n(the relay did not answer just now)' : ` (${r.roomFiles} file(s) in it)`}`)
634
+ if (r.lost) log(`\nTHE ROOM IS EMPTY: the relay lost it (a restart or a deploy wipes rooms on some hosts).\nAgents that still have their folders get their work back by joining again:\n ${agentLine}\nIf no agent has a folder any more, refill it from the branch:\n npx -y hivecode-mcp@latest branch ${r.branch} --reseed`)
635
+ } else {
636
+ const dirty = git(r.root, ['status', '--porcelain'])
637
+ const dirtyLines = dirty.out ? dirty.out.split('\n').filter(Boolean) : []
638
+ const dirtyFiles = dirtyLines.map((l) => {
639
+ const p = l.slice(3).trim()
640
+ return p.includes(' -> ') ? p.split(' -> ')[1].trim() : p
641
+ }).filter(Boolean)
642
+ const shown = dirtyFiles.slice(0, 5).join(', ')
643
+ const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
644
+ const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
645
+ log(`${r.reseeded ? 'Room refilled' : 'Room ready'} for branch ${r.branch} @ ${r.commit.slice(0, 7)}${r.created ? ` (new branch, cut from ${r.from || 'HEAD'}; your checkout was not switched)` : ''}\n ${r.files} file(s) in the room: the branch as committed${uncommittedPart}${r.total > r.files ? `\n ${r.total - r.files} more (binary, over 1 MB, or never synced) are filled in from git for agents on this machine` : ''}\n room: ${r.link}`)
646
+ if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
647
+ const roomName = parseLink(r.link).room
648
+ log(` Your agents work in their own folders under ~/hivecode/${roomName}/, kept in sync live.\n Your checkout here doesn't change until you run pr.`)
649
+ }
650
+ log(`\nTell each of your agents:\n ${agentLine}\n\nA teammate, in their own clone of this repo:\n npx -y hivecode-mcp@latest branch ${r.branch} --join "${r.link}"\n\nWhen the work is done, land it on ${r.branch} (one commit per agent):\n npx -y hivecode-mcp@latest pr ${r.branch} add --pr to push it and open the pull request`)
651
+ return 0
652
+ } catch (e) {
653
+ logErr(e && e.message ? e.message : String(e))
654
+ return 1
655
+ }
656
+ }