hivecode-mcp 0.13.0 → 0.13.1

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.
@@ -164,6 +164,66 @@ Notifications are optional: one Slack or Discord webhook URL. They are sent when
164
164
 
165
165
  Nothing is sent for each comment.
166
166
 
167
+ ## Room mode: multi-agent issues in a shared room
168
+
169
+ Room Mode hands labelled issues directly to agents already collaborating in a shared room bound to a branch.
170
+ Rather than spinning up isolated single-agent runs, the room's agents claim files, build, and review each other concurrently.
171
+ When work is complete and reviewed, Hivecode runs the project tests and opens or updates a single pull request.
172
+
173
+ ### Setup
174
+
175
+ 1. **Bind a room to a branch:**
176
+ ```bash
177
+ npx -y hivecode-mcp@latest branch <branch-name>
178
+ ```
179
+ This creates or binds a branch room on the relay and prints its join link (`wss://...|room-...`).
180
+
181
+ 2. **Agents join the room:**
182
+ Connect your agents (Claude Code, Codex, Antigravity, or human developers) into the room using `hive_join` or the room link. Each agent gets its own workspace folder and display name.
183
+
184
+ 3. **Start the room watcher:**
185
+ ```bash
186
+ npx -y hivecode-mcp@latest github start --room "<room link>" [--fallback-agent "<command>"] [--ack-minutes 10]
187
+ ```
188
+ The watcher polls GitHub for labelled issues and manages task dispatch and landings for that room.
189
+
190
+ ### What the person sees on GitHub
191
+
192
+ Every event is posted in order on the issue or the pull request. Room identifiers and relay URLs are never posted on GitHub.
193
+
194
+ | event | where | what you see |
195
+ |---|---|---|
196
+ | Issue labelled `hivecode` | Issue comment | `Sent to room for branch <name>. In the room now: <agents>` (lists active agents and humans separately; skips passive bots). |
197
+ | Tests fail during landing | Issue comment | `tests failed, sent back to the room (attempt 1 of 2)`. The failing test output is posted as a task into the room. Unchanged code is not re-tested. |
198
+ | Work complete and reviewed | Pull request opened | PR titled after the issue (`<Title> (#1)`), body with `Closes #1`, summary of changed files, test verification (`Tests: npm test passed on <sha>, the exact commit pushed`), and one commit per author. |
199
+ | Issue updated with PR | Issue comment | `landed as PR #N` linking the pull request. |
200
+ | PR review feedback | PR comment & Room task | Comments on the PR become a single room task naming the blamed author (`Sent 1 review comment to the agents`). When fixed and reviewed, fixes are pushed to the same PR (`fixes pushed`). |
201
+ | Unresponsive or stuck | Issue comment & Label | If `--max-minutes` passes or retries exhaust (`--max-runs`), the issue is labelled `hivecode:needs-human` with the last room transcript lines. |
202
+
203
+ ### `/hivecode stop` and `/hivecode retry`
204
+
205
+ - **/hivecode stop:** A comment starting with `/hivecode stop` on the issue or PR stops work immediately. It works mid-work: the room is notified, the active task is withdrawn, and nothing lands. A stop from someone who cannot push, or from before the run started, is ignored.
206
+ - **/hivecode retry:** A comment starting with `/hivecode retry` re-queues the issue. The watcher increments the attempt number and posts a new task (`Issue #12 (attempt 2): <title>`), matching the updated attempt so the room receives the retry cleanly without duplicate task suppression.
207
+
208
+ ### Fallback agent and token cost
209
+
210
+ - **Opt-in only:** `--fallback-agent "<command>"` is strictly opt-in. Unattended agents never run unless explicitly configured.
211
+ - **Reachability & engagement gate:** If reachable agents are present in the room when an issue arrives, no fallback starts before `--ack-minutes` (default 10). If any agent engages (claims a file or chats intent), fallback is prevented. If 0 reachable agents exist at post time, fallback spawns immediately.
212
+ - **Budget caps:** Fallback runs are counted against `--max-runs-per-day` (default 20 across the repo) and run at most once per issue. When the daily limit is reached, no fallback spawns, and an issue comment notes that the limit was reached.
213
+
214
+ ### The review rule and lone-agent waiver
215
+
216
+ - **Non-writer review required:** A task marked done will not land until a non-writer agent submits a passing review (`hive_review({ files, verdict: 'pass' })`). Reviews authored by contributors to the current held files are ignored.
217
+ - **Lone-agent waiver:** If only one agent is present in the room and no second agent arrives before `--ack-minutes` (default 10), Hivecode waives the second-agent requirement and lands the PR.
218
+ - **PR waiver notice:** The PR body explicitly states: `"not reviewed by a second agent"`, making the single-agent status transparent to human reviewers on GitHub. A `VERDICT: FAIL` review is never waived.
219
+
220
+ ### Known limits
221
+
222
+ - **One issue at a time:** The watcher processes one issue at a time per room; subsequent labelled issues queue and wait for the active PR to be merged or closed.
223
+ - **One open PR:** The room maintains at most one open PR at a time to keep branch history and CRDT file states consistent.
224
+ - **Squash-merge history:** When PRs are squash-merged on GitHub, git commits are condensed to one, but GitHub preserves individual contributor names as `Co-authored-by:` trailers.
225
+ - **Antigravity multi-window MCP session sharing:** In Antigravity IDE, multiple windows on the same machine share a single background MCP server process. Parallel agents must use separate workspace folders and distinct agent names (e.g. `antigravity-2`, `antigravity-4`) or standalone sessions to avoid `.hive-mcp-session.json` identity collisions.
226
+
167
227
  ## Why `gh` and not a GitHub App, for now
168
228
 
169
229
  - **With `gh`, nothing new is exposed.**
@@ -85,3 +85,21 @@ This guide explains every error code (`Qxxx`) emitted by Hivecode CLI commands,
85
85
  | `Q642` | `hive-notify.js` | Running `notify` | Missing `--for <name>` argument or non-positive `--every` interval. | Specify who to notify with `--for <name>` and positive minutes with `--every <minutes>`. |
86
86
  | `Q643` | `hive-notify.js` | Running `notify` | The webhook endpoint refused the initial test post. | Check the webhook URL and ensure the receiving service is accessible. |
87
87
  | `Q644` | `hive-notify.js` | Running `notify` | A notifier is already running for this room (lock file held by another process). | Stop the other running notifier process or let it run. |
88
+ | `Q575` | `hive-land.js` | Landing branch changes into a pull request (`hive_land` / `landIssue`) | A file claim is still held by a participant (or appeared during landing). All files must be released before landing. | Release all held files using `hive_release`, or wait for the holder to finish and release their claims. |
89
+ | `Q576` | `hive-land.js` | Running project tests during landing verification (`--test "<command>"`) | The verification tests failed, timed out, or test checkout was not at the exact commit being landed. | Inspect test error output in room task or log. If tests require uncommitted files or dependencies, pass `--test-copy <files>` or `--test-setup <cmd>`, or fix failing test code. |
90
+ | `Q577` | `hive-land.js` | Landing branch changes into a pull request | The room holds no files or landing would delete most branch files (>50% mass deletion). Indicates relay restarted or room was wiped. | Reconnect agents to reseed the room, reseed from branch (`branch <name> --reseed`), or pass `--allow-mass-delete` if intentional. |
91
+ | `Q578` | `hive-land.js` | Pushing landed commits to remote origin (`git push origin <branch>`) | Pre-push checks failed: `origin` remote missing or unreachable, remote branch ahead, workflow files modified without `--allow-workflow-changes`, or secrets detected. | Ensure `origin` is configured and accessible, pull latest branch commits, or remove secrets/workflow changes from landed files. |
92
+ | `Q579` | `hive-land.js` | Starting a landing run for a branch | Another landing run for this branch is currently active (landing lock held). | Wait for the concurrent landing to finish, or remove stale landing lock if the prior process crashed. |
93
+ | `Q582` | `hive-repos.js` | Running `pr` from within a room | The room's repository folder is not located on this local machine. | Run `pr` from the machine where the repository clone resides, or register the repository with `hive-repos`. |
94
+ | `Q583` | `hive-repos.js`, `hive-branch.js` | Running `pr` to land changes from a room | No local git repository is registered or bound to this room on this machine. | In your repository checkout, run `npx hivecode-mcp branch <branch>` to bind the room to your repository. |
95
+ | `Q584` | `hive-repos.js` | Looking up registered repository for room | Repository registration is stale (folder moved/deleted) or multiple local clones are registered for the same room. | Re-bind the correct repository clone using `npx hivecode-mcp branch <branch>`, or remove obsolete entries in `~/.hivecode/repos.json`. |
96
+ | `Q585` | `hive-repos.js` | Calling `hive_pr` | Not currently connected to a room (`hive_join` has not been called). | Join a room first using `hive_join` before calling `hive_pr`. |
97
+ | `Q586` | `hive-repos.js` | Calling `hive_pr` | Room connection is currently offline or disconnected from relay. | Wait for the room connection to reconnect to the relay before running `hive_pr`. |
98
+ | `Q650` | `hive-room-mode.js` | Starting room-mode watcher (`github start --room "<link>"`) | Missing, empty, or malformed room join link. | Provide a valid room join link (`wss://<relay>|room-<id>`) or use `github start --branch <name>`. |
99
+ | `Q651` | `hive-room-mode.js` | Starting room-mode watcher | Target directory is not a git repository root (or is the user's home directory). | Run the watcher from inside a valid git repository checkout. |
100
+ | `Q652` | `hive-room-mode.js` | Starting room-mode watcher | The specified room is not bound to a git branch in this repository, or multiple branches match. | Run `npx hivecode-mcp branch <branch>` to bind the branch, or launch with `github start --branch <branch>`. |
101
+ | `Q653` | `hive-room-mode.js` | Preflight check for room-mode watcher | GitHub CLI (`gh`) is not installed, not in `PATH`, or not authenticated. | Install `gh` and run `gh auth login` with write permissions to the repository. |
102
+ | `Q654` | `hive-room-mode.js` | Preflight member version check for room mode | An active AI agent in the room is running a Hivecode version older than 0.13.0, or has no version declared. | Update all participating AI agent packages to `hivecode-mcp@latest` (>= 0.13.0) and reconnect. |
103
+ | `Q655` | `hive-room-mode.js` | Starting room-mode watcher | Another room-mode watcher is already active for this room (watcher lock held). | Stop the other running watcher process, or remove the stale lock file in `.git/hive-room-watcher.lock` if crashed. |
104
+ | `Q656` | `hive-room-mode.js` | Room-mode watcher connecting to relay | Could not connect to relay at specified URL, or connection timed out. | Verify internet connectivity, check relay status, and ensure the relay URL is correct. |
105
+ | `Q657` | `hive-room-mode.js` | Dispatching an issue task or chat into room | Delivery verification failed: task assignment or chat message was not confirmed in room within timeout. | Check relay stability, ensure room has reachable participants, and retry with `/hivecode retry`. |
@@ -0,0 +1,44 @@
1
+ // hive-authors.js -- who wrote what the room holds: the ONE authorsOf, shared by sync.js and hive-pr.js. A receipt counts only when verify(r) === true or verify(r).ok === true (a truthy {ok:false} used to count).
2
+ import { contentHash } from './substrate.js'
3
+
4
+ /**
5
+ * Check whether a receipt is valid according to the optional verify function.
6
+ * Handles both boolean returns and { ok: boolean } result objects (verifyReceipt).
7
+ */
8
+ export function isReceiptValid(verify, r) {
9
+ if (!verify) return true
10
+ const v = verify(r)
11
+ return v === true || Boolean(v && v.ok)
12
+ }
13
+
14
+ /**
15
+ * The authors of a file's held content, newest-landed first, from verified receipts only.
16
+ */
17
+ export function authorsOf(receipts, verify, roomText) {
18
+ const ok = (receipts || []).filter((r) => r && r.author && isReceiptValid(verify, r))
19
+ if (!ok.length) return { last: null, contributors: [], first: 0, receipts: [] }
20
+ const want = typeof roomText === 'string' ? contentHash(roomText) : null
21
+ let idx = -1
22
+ if (want) {
23
+ for (let i = ok.length - 1; i >= 0; i--) {
24
+ if (ok[i].contentHash === want) {
25
+ idx = i
26
+ break
27
+ }
28
+ }
29
+ }
30
+ const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
31
+ const last = held[held.length - 1]
32
+ const seen = new Map()
33
+ for (const r of held) {
34
+ if (!seen.has(r.author)) {
35
+ seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
36
+ }
37
+ }
38
+ return {
39
+ last: last ? { author: last.author, name: last.name || 'agent', at: last.at || 0 } : null,
40
+ contributors: [...seen.values()],
41
+ first: Math.min(...ok.map((r) => r.at || 0)),
42
+ receipts: held,
43
+ }
44
+ }
package/hive-await.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // hive-await — block until someone speaks to you, then exit. The CLI's `hive_wait`.
3
3
  //
4
- // npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]
4
+ // npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]
5
5
  //
6
6
  // Run it in the BACKGROUND and let its exit wake you. Exits 0 in both cases; read `reason` on stdout:
7
7
  // {"reason":"mention","messages":[...]} someone said @YourName or @all -> read, reply, run it again
@@ -37,15 +37,20 @@ import { startSync, parseLink } from './sync.js'
37
37
  import * as RC from './hive-receipts.js'
38
38
  import { awaitDir, armedFile, BEAT_MS } from './hive-listen.js'
39
39
 
40
- const [, , LINK = '', NAME = '', IDLE_MIN = '10'] = process.argv
40
+ const [, , LINK = '', NAME = '', IDLE_MIN = '10', APP_ARG = ''] = process.argv
41
41
  if (!LINK || !NAME) {
42
- console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]')
42
+ console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]')
43
43
  process.exit(2)
44
44
  }
45
45
  const { relay, room } = parseLink(LINK)
46
46
  const dir = awaitDir(NAME)
47
+ // Which app will be woken when this listener exits: passed by listenCommand, or inferred from the
48
+ // session environment when the agent ran `node hive-await.js <link> <name> <min>` directly. Without
49
+ // `app` here, a listener running while the MCP connection is closed or restarting leaves `app`
50
+ // undefined in awareness, and hive-activity.js misreports the agent as "not woken by it".
51
+ const APP = APP_ARG || process.env.HIVE_APP || (process.env.CLAUDE_CODE_SESSION_ID ? 'claude-code' : (process.env.ANTIGRAVITY_CONVERSATION_ID || process.env.ANTIGRAVITY_AGENT ? 'antigravity' : ''))
47
52
 
48
- const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, log: () => {} })
53
+ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, app: APP, log: () => {} })
49
54
 
50
55
  // ARMED, AND THE NEWEST LISTENER WINS (4 Oct 2026; see hive-listen.js). This file is how the Stop
51
56
  // hook and the MCP server know, with no network call, that the agent will hear the room after its
@@ -55,7 +60,7 @@ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: proces
55
60
  // listener that finds the file naming another process leaves: that one was started after it.
56
61
  const ARMED = armedFile(NAME, room)
57
62
  const SESSION = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
58
- const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}`
63
+ const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}${APP_ARG ? ` "${APP_ARG}"` : ''}`
59
64
  const startedAt = Date.now()
60
65
  let synced = false
61
66
  const readArmed = () => { try { return JSON.parse(fs.readFileSync(ARMED, 'utf8')) } catch { return null } }
@@ -77,7 +82,7 @@ const newerThanMe = (rec) => {
77
82
  function beat() {
78
83
  try {
79
84
  fs.mkdirSync(path.dirname(ARMED), { recursive: true })
80
- const body = JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, at: startedAt, beat: Date.now(), synced })
85
+ const body = JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, ...(APP ? { app: APP } : {}), at: startedAt, beat: Date.now(), synced })
81
86
  const part = `${ARMED}.${process.pid}.tmp`
82
87
  try { fs.writeFileSync(part, body); fs.renameSync(part, ARMED) } catch {
83
88
  try { fs.rmSync(part, { force: true }) } catch {}
package/hive-branch.js CHANGED
@@ -31,7 +31,6 @@ import os from 'os'
31
31
  import path from 'path'
32
32
  import crypto from 'crypto'
33
33
  import { spawnSync } from 'child_process'
34
- import { fileURLToPath } from 'url'
35
34
  import ignore from 'ignore'
36
35
  import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
37
36
  import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
@@ -40,6 +39,10 @@ import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
40
39
  import { isSafeRelPath } from './token.js'
41
40
 
42
41
  const TEXT_MAX = 1_000_000 // sync.js MAX_BYTES: a larger file never crosses the relay
42
+
43
+ // For hive-land.js, the one landing path. Declared below; exported here so the landing reads the
44
+ // binding, the branch and the room with exactly the rules this module binds and seeds with.
45
+ export { projectRoot, validBranch, revParse, coded, safeSegment, isOwnFile, git as gitIn, waitFor }
43
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
44
47
  const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
45
48
 
@@ -327,6 +330,13 @@ async function seedRoom({ root, branch, commit, link, you, log }) {
327
330
  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.`)
328
331
  }
329
332
  const short = commit.slice(0, 7)
333
+ // Who holds this room's repo, for display only: an agent on another machine asked to open a PR
334
+ // is told whom to ask. Never a path; what lands is decided on the landing machine alone.
335
+ try { const R = await import('./hive-repos.js'); R.writeRepoHolder(seeder.doc, { branch, holder: you, host: os.hostname() }) } catch { /* display only */ }
336
+ // THE COMMIT THE ROOM STARTS FROM, for a teammate's `--join`: without it their clone cannot tell
337
+ // a file the room deleted from one added to the branch later, and landing from there skipped the
338
+ // room's deletions -- and the Q572 guard -- without a word (antigravity, 5 Nov 2026).
339
+ try { seeder.doc.getMap('seed').set('commit', commit); seeder.doc.getMap('seed').set('branch', branch) } catch { /* older relay docs */ }
330
340
  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.`)
331
341
  await sleep(600) // let the announcement leave before the socket closes
332
342
  return { dir, count: expected.size, total: files.length }
@@ -355,6 +365,25 @@ async function roomPaths(link, you) {
355
365
  }
356
366
  const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
357
367
 
368
+ /** The commit the room was seeded from, as its seeder recorded it ({ commit, branch }), or null. */
369
+ async function roomSeed(link, you) {
370
+ const { relay, room } = parseLink(link)
371
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
372
+ const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
373
+ try {
374
+ let synced = false
375
+ peek.provider.on('sync', (s) => { if (s) synced = true })
376
+ if (!(await waitFor(() => synced, 15000))) return null
377
+ await sleep(300)
378
+ const m = peek.doc.getMap('seed')
379
+ const commit = m.get('commit')
380
+ return typeof commit === 'string' && /^[0-9a-f]{40}$/i.test(commit) ? { commit, branch: String(m.get('branch') || '') } : null
381
+ } finally {
382
+ try { peek.stop() } catch { }
383
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
384
+ }
385
+ }
386
+
358
387
  /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
359
388
  function defaultBase(root, name) {
360
389
  const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
@@ -383,6 +412,15 @@ export function removeRoomFolder(dir, room) {
383
412
  fs.rmSync(abs, { recursive: true, force: true })
384
413
  }
385
414
 
415
+ const projectRootOrNull = (cwd) => { try { return projectRoot(cwd) } catch { return null } }
416
+
417
+ // THIS MACHINE'S RECORD OF WHICH REPO HOLDS WHICH ROOM (hive-repos.js), so an agent that joined by
418
+ // link -- not by branch -- can still land from here with hive_pr. Imported late: hive-repos imports
419
+ // this module. Best effort: a registry that cannot be written never fails a bind, and hive_pr then
420
+ // says exactly that the room is not bound on this machine (Q583), with the command that fixes it.
421
+ async function registerLocal(root, branch, link) {
422
+ try { const R = await import('./hive-repos.js'); return !!R.registerRepo({ root, branch, link }) } catch { return false }
423
+ }
386
424
  function youFrom(root, given) {
387
425
  const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
388
426
  return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
@@ -403,14 +441,17 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
403
441
  const { relay: jr, room } = parseLink(join)
404
442
  if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
405
443
  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}"`)
406
- writeBinding(root, name, { room: join })
407
- return { root, branch: name, link: join, you: me, joined: true }
444
+ const seed = await roomSeed(join, me)
445
+ writeBinding(root, name, { room: join, base: seed && seed.commit ? seed.commit : '' })
446
+ await registerLocal(root, name, join)
447
+ return { root, branch: name, link: join, you: me, joined: true, base: seed && seed.commit ? seed.commit : '' }
408
448
  }
409
449
 
410
450
  if (existing && !reseed) {
411
451
  const n = await roomFileCount(existing.link, me)
412
452
  // Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
413
453
  const lost = n === 0 && Number(existing.files || 1) > 0
454
+ await registerLocal(root, name, existing.link) // a binding made before the registry existed gets its entry
414
455
  return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
415
456
  }
416
457
 
@@ -459,166 +500,27 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
459
500
  throw e
460
501
  }
461
502
  writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
503
+ await registerLocal(root, name, link)
462
504
  return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
463
505
  }
464
506
 
465
507
  // ---- landing: the room's work becomes commits on the branch ---------------------------------
466
508
 
467
- const HIVE_PR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hive-pr.js')
468
- const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
469
-
470
- /**
471
- * THE ROOM'S DELETIONS, which hive-pr does not land: it copies the files a room holds, so a file an
472
- * agent deleted would simply stay on the branch, in a PR that looks complete. A room bound to a
473
- * branch starts as the whole branch, so "on the branch but no longer in the room" is a deletion --
474
- * but only when the room provably held THAT version of the file. For each such path:
475
- * - last changed on the branch by a landing from this room (its commit says "Room: <id>") -> delete
476
- * - unchanged since the seed commit -> delete
477
- * - in the seed, but changed on the branch since by someone else -> REFUSE (Q572):
478
- * deleting it would erase a change the room never saw
479
- * - not in the seed and not landed from this room: added to the branch outside the room -> keep
480
- */
481
- async function roomDeletions({ root, link, base, tip }) {
482
- if (!base) return []
483
- const paths = await roomPaths(link, 'hive-land')
484
- 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.`)
485
- const room = parseLink(link).room
486
- const atTip = treeFiles(root, tip)
487
- const carries = roomCarries(atTip)
488
- const baseSha = new Map(treeFiles(root, base).map((f) => [f.rel, f.sha]))
489
- const gone = [], diverged = []
490
- for (const f of atTip) {
491
- if (!carries(f) || paths.has(f.rel)) continue
492
- const last = git(root, ['log', '-1', '--format=%B', tip, '--', f.rel]).out
493
- if (new RegExp(`^Room: ${reEsc(room)}$`, 'm').test(last)) gone.push(f.rel)
494
- else if (baseSha.get(f.rel) === f.sha) gone.push(f.rel)
495
- else if (baseSha.has(f.rel)) diverged.push(f.rel)
496
- }
497
- if (diverged.length) {
498
- const who = (rel) => git(root, ['log', '-1', '--format=%h %an %ad %s', '--date=short', tip, '--', rel]).out
499
- 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.`)
500
- }
501
- return gone
502
- }
503
-
504
- function worktreesOn(root, branch) {
505
- const r = git(root, ['worktree', 'list', '--porcelain'])
506
- const out = []
507
- let cur = null
508
- for (const line of r.out.split('\n')) {
509
- if (line.startsWith('worktree ')) cur = line.slice(9)
510
- else if (line === `branch refs/heads/${branch}` && cur) out.push(path.resolve(cur))
511
- }
512
- return out
513
- }
514
-
515
509
  /**
516
- * `pr <name>`: land the room's work on the bound branch as one commit per agent.
510
+ * `pr <name>` used to land here. Landing now has ONE path, hive-land.js landAndOpen, shared by the
511
+ * `pr` command, the hive_pr tool and the GitHub watcher: every check (claims, an empty room, workflow
512
+ * files, keys, tests, origin ahead, the lock) protects all three. Two copies of the landing had
513
+ * already drifted once (the deletion accounting lived only here).
517
514
  *
518
- * hive-pr writes the commits (attribution and the staleness check are all its own), but it runs in
519
- * a TEMPORARY WORKTREE detached at the branch tip, because hive-pr switches the checkout it is given
520
- * and the developer's must never be switched. The branch then moves by compare-and-swap from the tip
521
- * the worktree was cut from, so a commit that reached the branch meanwhile is refused, never lost.
515
+ * landBranch keeps its old contract for the watcher, which still runs its own workflow, key and test
516
+ * checks after landing, and whose agent has exited by the time it lands (so its claims are moot):
517
+ * { ok:true, landed, tip, deletions } | { ok:false, code } with the reason on logErr.
522
518
  */
523
519
  export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
524
- const root = projectRoot(cwd)
525
- const name = validBranch(root, branch)
526
- const b = readBinding(root, name)
527
- if (!b) throw coded('Q562', `branch "${name}" has no Hivecode room here. Create one with: npx -y hivecode-mcp@latest branch ${name}`)
528
- const tip = revParse(root, `refs/heads/${name}`)
529
- if (!tip) throw coded('Q563', `branch "${name}" does not exist in ${root}. Fetch it first: git fetch origin ${name}:${name}`)
530
-
531
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-'))
532
- const temp = `hivecode-land/${safeSegment(name)}-${process.pid}-${Date.now()}`
533
- const cleanup = () => {
534
- git(root, ['worktree', 'remove', '--force', tmp])
535
- try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { }
536
- git(root, ['worktree', 'prune'])
537
- if (revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
538
- }
539
- const wt = git(root, noHooks(['worktree', 'add', '--detach', tmp, tip]))
540
- if (wt.code !== 0) { cleanup(); throw coded('Q563', `could not make a temporary worktree: ${wt.err}`) }
541
-
542
- try {
543
- // Accounted for BEFORE anything is written, so a refusal (Q572) leaves the branch as it was.
544
- const deletions = await roomDeletions({ root, link: b.link, base: b.base, tip })
545
-
546
- const args = [HIVE_PR, b.link, 'hive-pr', '--repo', tmp, '--branch', temp, '--base', 'HEAD']
547
- if (dry) args.push('--dry-run')
548
- if (force) args.push('--force', force)
549
- if (only) args.push('--only', only)
550
- const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: noHooksEnv({ ...process.env, HIVE_PROVENANCE: 'on' }), maxBuffer: 64 * 1024 * 1024, windowsHide: true })
551
- if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
552
- // Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
553
- // added. For a branch room that is an answer, not a failure -- and deletions may still land.
554
- const nothingChanged = r.status !== 0 && /^Q501 /m.test(r.stderr || '')
555
- if (r.stderr && !nothingChanged) logErr(r.stderr.replace(/\s+$/, ''))
556
- if (r.status !== 0 && !nothingChanged) return { ok: false, code: r.status || 1 }
557
- if (dry) {
558
- if (deletions.length) log(`would delete ${deletions.length} file(s) the room deleted: ${deletions.join(', ')}`)
559
- return { ok: true, dry: true, deletions }
560
- }
561
-
562
- if (deletions.length) {
563
- // hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
564
- // commit it never made one, so make it here, at the same tip.
565
- if (!revParse(root, `refs/heads/${temp}`)) git(tmp, noHooks(['checkout', '-q', '-b', temp]))
566
- const rm = git(tmp, ['rm', '-q', '--', ...deletions])
567
- if (rm.code !== 0) throw coded('Q563', `could not stage the room's deletions: ${rm.err}`)
568
- const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
569
- const c = git(tmp, noHooks(['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>']))
570
- if (c.code !== 0) throw coded('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
571
- }
572
- // NOTHING NEW TO LAND IS NOT "DONE" WHEN A PUSH OR A PR WAS ASKED FOR. The usual sequence is a
573
- // plain `pr` to look, then `pr --pr`; the second run lands nothing, and returning here skipped
574
- // the push and the PR while printing success (1 Nov 2026). Push and open what is already there.
575
- let nothingNew = false
576
- if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); nothingNew = true }
577
- const newTip = nothingNew ? tip : revParse(root, `refs/heads/${temp}`)
578
- if (!nothingNew && (!newTip || newTip === tip)) { log(`nothing to land: ${name} already has everything the room holds.`); nothingNew = true }
579
- if (nothingNew && !push && !pr) return { ok: true, landed: 0 }
580
- if (nothingNew) return finish('')
581
-
582
- // Move the branch. Checked out somewhere: only a clean fast-forward of that checkout, which is
583
- // what `git pull` would do; with local changes it is refused, because moving the branch under a
584
- // dirty checkout makes its edits look like reversals of the landed work.
585
- const on = worktreesOn(root, name).filter((p) => p !== path.resolve(tmp))
586
- if (on.length) {
587
- const where = on[0]
588
- const dirty = git(where, ['status', '--porcelain', '--untracked-files=no'])
589
- 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.`)
590
- const ff = git(where, ['merge', '--ff-only', '--quiet', newTip])
591
- 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.`)
592
- } else {
593
- const cas = git(root, ['update-ref', '-m', 'hivecode: land room work', `refs/heads/${name}`, newTip, tip])
594
- 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.`)
595
- }
596
- const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
597
- log(`\nlanded on ${name}:\n${made}`)
598
- return finish(made)
599
-
600
- // `made` is '' when nothing new landed this run: the push and the PR are of what is already on
601
- // the branch, and the PR body lists the branch's commits since its base instead.
602
- function finish(made) {
603
- if (push) {
604
- const p = git(root, ['push', '-u', 'origin', name])
605
- if (p.code !== 0) throw coded('Q573', `the work ${made ? 'landed' : 'is'} on ${name} locally, but the push failed: ${p.err}`)
606
- log(`pushed ${name}`)
607
- }
608
- if (pr) {
609
- const into = base || b.from
610
- if (!into) throw coded('Q574', `the work is on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
611
- const listed = made || git(root, ['log', '--format= %h %an %s', `${into}..${name}`]).out
612
- 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.', '', '```', listed, '```'].join('\n')
613
- const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
614
- if (g.status !== 0) throw coded('Q574', `the work is on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
615
- log((g.stdout || '').trim())
616
- }
617
- return { ok: true, landed: made ? made.split('\n').filter(Boolean).length : 0, tip: made ? newTip : tip, deletions }
618
- }
619
- } finally {
620
- cleanup()
621
- }
520
+ const { landAndOpen } = await import('./hive-land.js')
521
+ const r = await landAndOpen({ cwd, branch, dry: !!dry, push, pr, base, force, only, title, log, logErr, claims: false, checks: false, by: 'the Hivecode GitHub watcher' })
522
+ if (!r.ok) { logErr(r.why); return { ok: false, code: 1, refused: r.code } }
523
+ return r.dry ? { ok: true, dry: true, deletions: r.deletions } : { ok: true, landed: r.landed, tip: r.tip, deletions: r.deletions }
622
524
  }
623
525
 
624
526
  // ---- CLI ----------------------------------------------------------------------------------
@@ -630,7 +532,7 @@ function flags(argv) {
630
532
  const a = argv[i]
631
533
  if (!a.startsWith('--')) { pos.push(a); continue }
632
534
  const k = a.slice(2)
633
- if (['dry-run', 'push', 'pr', 'reseed'].includes(k)) f[k] = true
535
+ if (['dry-run', 'push', 'pr', 'reseed', 'allow-workflow-changes', 'allow-mass-delete', 'save'].includes(k)) f[k] = true
634
536
  else f[k] = argv[++i] || ''
635
537
  }
636
538
  return { pos, f }
@@ -640,7 +542,8 @@ const USAGE = `usage:
640
542
  npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
641
543
  npx -y hivecode-mcp@latest branch <name> --join "<link>"
642
544
  npx -y hivecode-mcp@latest branch <name> --reseed
643
- npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--force "<reason>"]`
545
+ npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--test "<command>" [--test-copy <file,...>] [--test-setup "<cmd>"] [--save]] [--test-minutes <n>]
546
+ [--allow-workflow-changes] [--allow-mass-delete] [--title "<t>"] [--force "<reason>"]`
644
547
 
645
548
  /** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
646
549
  export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
@@ -650,8 +553,26 @@ export async function main(argv, { cwd = process.cwd(), log = console.log, logEr
650
553
  if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
651
554
  try {
652
555
  if (cmd === 'pr') {
653
- 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 })
654
- return r.ok ? 0 : r.code || 1
556
+ const { landAndOpen, testCommandFor, saveTestCommand } = await import('./hive-land.js')
557
+ let test = f.test || ''
558
+ // --test-copy a,b and --test-setup "<cmd>": what the tests need beyond git (see hive-land.js).
559
+ const testCopy = 'test-copy' in f ? String(f['test-copy'] || '').split(',').map((s) => s.trim()).filter(Boolean) : undefined
560
+ const testSetup = 'test-setup' in f ? String(f['test-setup'] || '') : undefined
561
+ if (f.save) {
562
+ if (!test) { logErr(`--save keeps a test command: pass it with --test "<command>"`); return 2 }
563
+ saveTestCommand(cwd, name, test, { copy: testCopy ?? null, setup: testSetup ?? null })
564
+ log(`saved: landing ${name} now runs ${testSetup ? `\`${testSetup}\`, then ` : ''}\`${test}\` first${testCopy && testCopy.length ? `, with ${testCopy.join(', ')} copied in` : ''}, from any caller (pr, hive_pr, room mode). Change it the same way.`)
565
+ }
566
+ // No --test: the command saved for this branch, if any. (The project's own `npm test` is the
567
+ // hive_pr tool's fallback; here the developer is present, so nothing runs they did not choose.)
568
+ if (!test) { const t = testCommandFor(cwd, name); if (t.from === 'saved') { test = t.command; log(`tests: \`${test}\` (saved for ${name})`) } }
569
+ const r = await landAndOpen({
570
+ 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 || '',
571
+ test, testMinutes: Number(f['test-minutes']) || 0, testCopy, testSetup, allowWorkflowChanges: !!f['allow-workflow-changes'], allowMassDelete: !!f['allow-mass-delete'],
572
+ by: youFrom(projectRootOrNull(cwd) || cwd, ''), log, logErr,
573
+ })
574
+ if (!r.ok) { logErr(r.why); return 1 }
575
+ return 0
655
576
  }
656
577
  const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
657
578
  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)`
package/hive-github.js CHANGED
@@ -1632,8 +1632,17 @@ function flags(argv) {
1632
1632
 
1633
1633
  const USAGE = `usage:
1634
1634
  npx -y hivecode-mcp@latest github start --agent "<agent command>" [options]
1635
+ npx -y hivecode-mcp@latest github start --room "<room link>" [--fallback-agent "<command>"] [--ack-minutes 10]
1636
+ npx -y hivecode-mcp@latest github start --branch <name> [--fallback-agent "<command>"] [--ack-minutes 10]
1635
1637
  npx -y hivecode-mcp@latest github status
1636
1638
 
1639
+ --room: hand each labelled issue to the agents ALREADY in that room (a room bound to a branch of
1640
+ this repo: npx -y hivecode-mcp@latest branch <name>). They split it, build it and review each other;
1641
+ Hivecode runs the tests and opens one pull request. One issue at a time.
1642
+ --branch <name> bind or use the room for this branch, and run room mode
1643
+ --fallback-agent "<command>" if nobody in the room takes an issue, start this agent in the room
1644
+ (opt-in: it spends that agent's tokens; capped by --max-runs-per-day)
1645
+
1637
1646
  The agent command reads its task on stdin. For Claude Code:
1638
1647
  --agent "claude -p --output-format json --allowedTools Bash Edit Write Read Glob Grep mcp__hivecode"
1639
1648
 
@@ -1686,6 +1695,16 @@ export async function main(argv, { cwd: here = process.cwd(), log = console.log,
1686
1695
  if (sub === 'status') {
1687
1696
  const s = loadState(file, log, { peek: true })
1688
1697
  log(describe(s, s.config || {}))
1698
+ // ROOM MODE keeps its own state (room-<room>.json, never github.json): describe each one, so
1699
+ // "why isn't it picking up my issue?" has an answer here too.
1700
+ try {
1701
+ const dir = path.join(gitDir, 'hivecode')
1702
+ const rooms = fs.existsSync(dir) ? fs.readdirSync(dir).filter((x) => /^room-.+\.json$/.test(x)) : []
1703
+ if (rooms.length) {
1704
+ const RW = await import('./hive-room-watcher.js')
1705
+ for (const x of rooms) log(`\n${RW.describeRoomState(RW.loadRoomState(path.join(dir, x)))}`)
1706
+ }
1707
+ } catch (e) { log(`(room mode state could not be read: ${e.message})`) }
1689
1708
  return 0
1690
1709
  }
1691
1710
 
@@ -1719,6 +1738,27 @@ export async function main(argv, { cwd: here = process.cwd(), log = console.log,
1719
1738
  return busy ? EXIT_AGAIN : EXIT_FIX
1720
1739
  }
1721
1740
  if (info.permissions && info.permissions.push === false) { logErr(`Q601 the GitHub account gh is logged in as cannot push to ${id.owner}/${id.repo}.`); return EXIT_FIX }
1741
+ // ROOM MODE: issues go to the agents ALREADY in a room (hive-room-watcher.js), and no agent of ours
1742
+ // runs unless --fallback-agent asks for one. Everything below this line is the other mode.
1743
+ if (opt('room') || ('branch' in f)) {
1744
+ if (f['dry-run']) { logErr('Q602 --dry-run does not work with --room yet. Use: npx -y hivecode-mcp@latest github status'); return EXIT_FIX }
1745
+ let roomLink = opt('room')
1746
+ if (!roomLink && ('branch' in f)) {
1747
+ const branchName = String(opt('branch') || '').trim()
1748
+ if (!branchName || branchName === 'true') { logErr('Q602 say which branch to watch, with --branch <name>.'); return EXIT_FIX }
1749
+ const HB = await import('./hive-branch.js')
1750
+ try {
1751
+ const bound = await HB.bindBranch({ cwd: root, branch: branchName, relay: opt('relay') || '', log })
1752
+ roomLink = bound.link
1753
+ f['room'] = roomLink
1754
+ } catch (e) {
1755
+ logErr(isQ(e) ? e.message : `Q603 ${e.message}`)
1756
+ return EXIT_FIX
1757
+ }
1758
+ }
1759
+ const RW = await import('./hive-room-watcher.js')
1760
+ return RW.runRoomMode({ f, opt, gh, id, info, root, gitDir, label: opt('label') || LABEL, log, logErr })
1761
+ }
1722
1762
  const agent = opt('agent')
1723
1763
  if (!agent) { logErr(`Q602 say which agent runs the work, with --agent "<command>".\n\n${USAGE}`); return EXIT_FIX }
1724
1764
  if (!commandExists(agent)) { logErr(`Q602 "${String(agent).split(/\s+/)[0]}" is not installed here (not on PATH).`); return EXIT_FIX }