hivecode-mcp 0.12.2 → 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-mcp.js CHANGED
@@ -36,15 +36,18 @@ import { startUpdateCheck, updateNotice } from './hive-update.js'
36
36
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
37
37
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
38
38
  import { startSync, parseLink } from './sync.js'
39
- import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
40
- import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
41
- import { INSTRUCTIONS } from './hive-instructions.js'
39
+ import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
40
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
41
+ import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
42
42
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
43
43
  import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
44
44
  import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
45
45
  import * as OB from './hive-obligations.js'
46
46
  import * as RC from './hive-receipts.js'
47
+ import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
47
48
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
49
+ import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
50
+ import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
48
51
 
49
52
  const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
50
53
  let session = null // { hive, room, relay, dir, name, mailbox }
@@ -87,11 +90,16 @@ function resolveRoomRaw(link, dir) {
87
90
  // its own MCP process from its own directory) resumes only its own identity. Lets the
88
91
  // session survive MCP host restarts, which otherwise silently drop the agent from the room.
89
92
  const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
90
- function persistSession({ dir, name, owner, cursor }) {
93
+ function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
91
94
  try {
92
95
  const prev = readSessionFile() || {}
93
96
  const chatCursor = cursor === undefined ? prev.chatCursor : cursor
94
- fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor }))
97
+ // `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
98
+ // tokens -- the agent's shell does not hold this server's token, so that listener could not join.
99
+ // `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
100
+ // AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
101
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
102
+ ...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}) }))
95
103
  } catch { /* best-effort */ }
96
104
  }
97
105
  function readSessionFile() {
@@ -100,7 +108,7 @@ function readSessionFile() {
100
108
  /** Save the read cursor so a host restart does not reopen the blind window it just closed. */
101
109
  function persistCursor() {
102
110
  if (!session || !session.mailbox) return
103
- persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
111
+ persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, authed: session.authed, kind: session.kind, passive: session.passive })
104
112
  // AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
105
113
  // what makes "did they actually read it" answerable by anybody other than me. The cursor only
106
114
  // advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
@@ -158,7 +166,7 @@ function defaultJoinTarget(link, name) {
158
166
  return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
159
167
  }
160
168
 
161
- async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
169
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '' }) {
162
170
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
163
171
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
164
172
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -170,6 +178,21 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
170
178
 
171
179
  This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
172
180
  }
181
+ // A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
182
+ // this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
183
+ // session is torn down, for the same reason as the name check above.
184
+ let branchCtx = null
185
+ if (branch) {
186
+ const untouched = '\n\nNothing was joined and your current session is untouched.'
187
+ if (link) return `Pass branch or link, not both: a branch's room link comes from the repo's git config.${untouched}`
188
+ const bound = HB.resolveForJoin({ branch, repo: repo || process.cwd() })
189
+ if (!bound.ok) return bound.message + untouched
190
+ link = bound.link
191
+ if (!dir) dir = HB.roomFolder(link, name)
192
+ const mark = HB.markNotACheckout(path.resolve(dir))
193
+ if (!mark.ok) return `Q564 ${mark.why}${untouched}`
194
+ branchCtx = bound
195
+ }
173
196
  if (session) { try { session.hive.stop() } catch {} ; session = null }
174
197
  let hostedHere = false
175
198
  if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
@@ -182,7 +205,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
182
205
  // nothing behind -- no config pointing at the wrong relay for the next process to find,
183
206
  // and above all no published file. Checking after connecting would report a leak instead
184
207
  // of preventing one, which is the difference between a guard and a log line.
185
- const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
208
+ const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs, room })
186
209
  if (!priv.ok) {
187
210
  return `${priv.headline}
188
211
 
@@ -216,7 +239,7 @@ was not written. Fix the relay and call hive_join again.`
216
239
  // folder is precisely the situation the lock exists to prevent.
217
240
  let hive
218
241
  try {
219
- hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
242
+ hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive, app: clientName() })
220
243
  } catch (e) {
221
244
  if (e && e.code === 'EHIVELOCKED') {
222
245
  return text(
@@ -226,8 +249,8 @@ was not written. Fix the relay and call hive_join again.`
226
249
  }
227
250
  throw e
228
251
  }
229
- session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
230
- persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
252
+ session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, authed: !!useToken, mailbox: null }
253
+ persistSession({ dir, name, owner, authed: !!useToken, kind: kind || 'ai', passive: !!passive }) // survive MCP host restarts (see tryResumeSession)
231
254
  // Wait for the first sync so members/board/rules are ready.
232
255
  //
233
256
  // REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
@@ -259,7 +282,7 @@ was not written. Fix the relay and call hive_join again.`
259
282
  // authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
260
283
  // Only when nothing refused us with a code do we query /health to infer whether it was capacity.
261
284
  if (refusedCode) {
262
- const explanation = RH.explainStatus(refusedCode, relay)
285
+ const explanation = RH.explainStatus(refusedCode, relay, { hasToken: !!useToken })
263
286
  return [
264
287
  `NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
265
288
  `room: ${room}`,
@@ -296,6 +319,29 @@ was not written. Fix the relay and call hive_join again.`
296
319
  'it reports `connected` and `filesSynced`, and both are authoritative.',
297
320
  ].join('\n')
298
321
  }
322
+ // ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
323
+ // a name already here is the same identity to everybody: both are told they hold each other's
324
+ // claims, both read each other's mail, and their writes land on each other's files. Measured
325
+ // 3 Oct 2026 -- see sync.js sameNameElsewhere. Refused here, before this session does anything.
326
+ let dupes = []
327
+ try { dupes = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { /* an engine that cannot tell */ }
328
+ if (dupes.length) {
329
+ const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
330
+ let n = 2
331
+ while (taken.has(`${name}-${n}`.toLowerCase())) n++
332
+ const d = dupes[0]
333
+ const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
334
+ try { hive.stop() } catch { }
335
+ session = null
336
+ try { fs.rmSync(SESSION_FILE) } catch { }
337
+ return [
338
+ `NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
339
+ 'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
340
+ 'other\'s mail, and show as one member, and your writes would land on each other\'s files.',
341
+ `Join again with a different name, e.g. hive_join { name: "${name}-${n}" } with the same link.`,
342
+ `If that ${d.name} is you a moment ago on this machine, wait 30 seconds for it to drop out, then join again.`,
343
+ ].join('\n')
344
+ }
299
345
  // The mailbox is created AFTER the first sync, so chat.length is the real room history and
300
346
  // not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
301
347
  // A resumed session carries its saved cursor forward instead, so anything posted while this
@@ -306,6 +352,7 @@ was not written. Fix the relay and call hive_join again.`
306
352
  persistCursor()
307
353
 
308
354
  const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
355
+ const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
309
356
 
310
357
  // Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
311
358
  hive.doc.getMap('swarm_state').observe(() => {
@@ -348,6 +395,7 @@ was not written. Fix the relay and call hive_join again.`
348
395
  try {
349
396
  const s = session
350
397
  if (!s || !s.mailbox) return
398
+ catchUp(s)
351
399
  const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
352
400
  // Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
353
401
  // 0, and a later single mention must still ring. `>` would go permanently silent.
@@ -382,12 +430,24 @@ was not written. Fix the relay and call hive_join again.`
382
430
  // The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
383
431
  // so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
384
432
  // defined, which made every hive_join return "error: rules is not defined".)
385
- let rulesText = ''
386
- try { rulesText = fs.readFileSync(path.join(ROOT, 'HIVE_RULES.md'), 'utf8') } catch {}
387
- if (!rulesText) rulesText = 'Read HIVE_RULES.md in this folder. In short: read chat + board before editing; CLAIM a file with hive_claim before you edit it and hive_release when done; announce intent with hive_say; prefer small patches; act only on accepted tasks; resolve <<<<<<< conflict markers rather than blindly overwriting.'
388
- // The invite link carries the token when there is one — without it a secured room rejects
389
- // whoever you hand it to, and the Control Room says the link is missing its token.
390
- const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
433
+ // THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
434
+ // session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
435
+ // the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
436
+ const rulesText = JOIN_RULES
437
+ // A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
438
+ // as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
439
+ // and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
440
+ // one folder. What is meant for others never carries a token now; the joiner's own Control Room
441
+ // link, which needs one, says whose it is; and a room that checks tokens says each person needs
442
+ // their own (a secured room rejects a link without one, which is the point).
443
+ const inviteLink = `${relay}|${room}`
444
+ // What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
445
+ const browserUrl = controlUrl(inviteLink)
446
+ const ownControl = useToken ? controlUrl(`${relay}|${room}|${useToken}`) : ''
447
+ const tokenNote = useToken
448
+ ? `\nthis room checks tokens: each person joins with a token of their own. Mint one per person with npx -y -p hivecode-relay hivecode-token --name <them> --room "${room}" --role agent (add --paths to limit them) and send it to them directly. Never pass on yours: whoever holds it joins as you.` +
449
+ (ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : '')
450
+ : ''
391
451
  // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
392
452
  // who has not run a relay -- the public one and a company's own look identical. The
393
453
  // bracketed verdict is what makes "did my source just leave the building?" answerable
@@ -399,9 +459,46 @@ was not written. Fix the relay and call hive_join again.`
399
459
  ? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
400
460
  `It now uses ${relay}, and .hive.json has been updated, so this happens once.`
401
461
  : ''
402
- return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${movedNote}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.${updateNotice() ? '\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
462
+ // What the room cannot carry (binary, over 1 MB, always-ignored) comes from git, once the room's
463
+ // own files are known. Only those: a text file missing here was deleted in the room.
464
+ let branchNote = ''
465
+ if (branchCtx) {
466
+ let filled = 0
467
+ let fillErr = ''
468
+ if (branchCtx.commit) { try { filled = HB.fillFromGit({ root: branchCtx.root, commit: branchCtx.commit, dir: ROOT }) } catch (e) { fillErr = e.message } }
469
+ branchNote = `BRANCH ROOM: this room is git branch "${branchCtx.branch}" of ${branchCtx.root}.\n` +
470
+ `Edit files in YOUR folder (${ROOT}), never in ${branchCtx.root}. It is deliberately not a git checkout: git refuses to run there, so no git command can rewrite the room's files. Do not commit; the work lands on "${branchCtx.branch}" as one commit per agent when the developer runs: npx -y hivecode-mcp@latest pr ${branchCtx.branch}` +
471
+ (filled ? `\nFilled from git: ${filled} file(s) the room does not carry (binary, over 1 MB, or never synced).` : '') +
472
+ (fillErr ? `\nCould not fill non-text files from git (${fillErr}); binary files may be missing here.` : '') + '\n\n'
473
+ }
474
+ // THE INVITE, READY TO FORWARD (onboarding audit, 2 Oct 2026). The host used to assemble it by
475
+ // hand: the install, the reload step, the sentence, the link. setup given a link prints that
476
+ // person's reload steps and their exact sentence, so one message covers all of it. Host only:
477
+ // this reply stays in the transcript, and a joiner already has the link it came in by.
478
+ // One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
479
+ // apps as a wall of links).
480
+ const people = browserUrl ? `\n No AI agent? Open ${browserUrl}` : ''
481
+ const invite = /^HOSTED/.test(mode)
482
+ ? `\nto invite someone, forward this as is:\n Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:\n npx -y hivecode-mcp@latest setup "${inviteLink}"${people}`
483
+ : (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
484
+ // No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
485
+ // (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
486
+ return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\ninvite link: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
487
+ }
488
+
489
+ // A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
490
+ // nobody, and agents are told to skip messages that name nobody; the joiner can act, so the join
491
+ // reply names every AI member whose reported build differs. Humans carry no engine build, and a
492
+ // client that reports none is the room's warning to give (checkBuilds speaks for old engines).
493
+ function mixedLine(members, me, label) {
494
+ const diff = (members || []).filter((m) => m && m.name && m.name !== me && String(m.kind || '').toLowerCase() !== 'human' && m.hv && m.hv !== label)
495
+ if (!diff.length) return ''
496
+ return `\n⚠ MIXED BUILDS: ${diff.map((m) => `${m.name} runs ${m.hv}`).join(', ')}; you run ${label}. Two builds in one room can lose file content: tell your person, so everyone runs the same build.`
403
497
  }
404
498
 
499
+ // Connected-or-not without status()'s file counts, which stat every file in the room. Falls back
500
+ // to status() for a hive that predates connection() (a test double, an older engine).
501
+ const connectionOf = (hive) => (typeof hive.connection === 'function' ? hive.connection() : hive.status())
405
502
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
406
503
  /** The read cursor for this participant. Lazily built if a session predates it. */
407
504
  function mailbox() {
@@ -411,8 +508,24 @@ function mailbox() {
411
508
  // rather than two that disagree.
412
509
  try { throttle.sent('rules', RULES_NUDGE) } catch { }
413
510
  if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
511
+ catchUp(s)
414
512
  return s.mailbox
415
513
  }
514
+ // THE LISTENER READS MAIL TOO (3 Oct 2026). An agent on Claude Code or Antigravity is woken by
515
+ // hivecode-await, a separate process with a cursor of its own, so every message it delivered was
516
+ // still unread here: the next tool call announced "UNREAD MAIL: N ... Handle before continuing"
517
+ // about mail the agent had already acted on (19 of them in one session), and an agent that obeys
518
+ // redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
519
+ // whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
520
+ // a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
521
+ function catchUp(s) {
522
+ if (!s || !s.mailbox) return
523
+ try {
524
+ const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
525
+ const at = (c && Number(c.at)) || 0
526
+ if (at > s.mailbox.cursor && at <= s.hive.doc.getArray('chat').length) s.mailbox.seek(at)
527
+ } catch { /* a room that predates receipts */ }
528
+ }
416
529
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
417
530
 
418
531
  // HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
@@ -539,61 +652,116 @@ function waitForWork(timeoutMs, opts = {}) {
539
652
  })
540
653
  }
541
654
 
655
+ // SAID ONCE, THEN SHORT (27 Sep 2026). An explanation is worth its tokens the first time an agent
656
+ // meets a rule in a session and nothing after that: results stay in the transcript, so a paragraph
657
+ // repeated on every call is paid again on every later turn. The warning itself always stays.
658
+ const explained = new Set()
659
+ const firstTime = (key) => (explained.has(key) ? false : (explained.add(key), true))
660
+
661
+ // A ROOM CHANGE THAT COULD NOT LAND HERE (3 Oct 2026). sync.js tells the room when a file in this
662
+ // folder is read-only or locked and so stays behind the room's copy, but that line goes out in this
663
+ // agent's own name, and an agent is never handed its own lines: the one participant working from
664
+ // the stale copy was the one nobody told. Said once per file, on a call the agent was making anyway.
665
+ function unwritableNotice(s) {
666
+ try {
667
+ const fresh = (s.hive.unwritable ? s.hive.unwritable() : []).filter((u) => firstTime('unwritable:' + u.path))
668
+ if (!fresh.length) return ''
669
+ const shown = fresh.slice(0, 5)
670
+ return `\n\n⚠ ${fresh.length} file(s) in your folder could not be written (read-only, or locked by another program), so your copy is OLDER than the room's:\n` +
671
+ shown.map((u) => ` - ${u.path} (${u.code})`).join('\n') +
672
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
673
+ `\nDo not work from that copy. Make it writable or close what holds it, or tell your person: it is written as soon as it can be, and anything you changed in it is merged, not lost.`
674
+ } catch { return '' }
675
+ }
676
+
677
+ // TWO ROOM FILES, ONE FILE ON THIS DISK (3 Oct 2026; sync.js checkCaseCollision). Case.js and case.js
678
+ // can both be in the room, but a Windows or Mac disk holds only one of them, so the other is not in
679
+ // this folder. The room is told in this agent's own name; this is where the agent is told.
680
+ function collidedNotice(s) {
681
+ try {
682
+ const fresh = (s.hive.collided ? s.hive.collided() : []).filter((c) => firstTime('collided:' + c.path))
683
+ if (!fresh.length) return ''
684
+ const shown = fresh.slice(0, 5)
685
+ return `\n\n⚠ ${fresh.length} room file(s) differ from a file in your folder only in upper/lower case. This disk cannot hold both, so they are NOT in your folder:\n` +
686
+ shown.map((c) => ` - ${c.path} (your folder has ${c.with})`).join('\n') +
687
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
688
+ `\nThey are separate files in the room: editing yours never changes the other. To work on one, ask whoever added it to rename it so the two names differ by more than case.`
689
+ } catch { return '' }
690
+ }
691
+
692
+ // A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
693
+ // is usually the one that needs telling, and the room line about it is in that agent's own name.
694
+ function withheldNotice(s) {
695
+ try {
696
+ const fresh = (s.hive.withheld ? s.hive.withheld() : []).filter((w) => firstTime('withheld:' + w.path))
697
+ if (!fresh.length) return ''
698
+ const shown = fresh.slice(0, 5)
699
+ return `\n\n⛔ ${fresh.length} file(s) in your folder were NOT shared, because each contains a credential (a key or token) the room does not have:\n` +
700
+ shown.map((w) => ` - ${w.path}: ${/^(?:[aeiou]|npm\b)/i.test(w.kind) ? 'an' : 'a'} ${w.kind} on line ${w.line}`).join('\n') +
701
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
702
+ `\nNobody else has your changes to these files. Read the key from the environment instead (put it in .env, which is never shared); each file is shared on its next save without the key.`
703
+ } catch { return '' }
704
+ }
705
+
542
706
  const TOOLS = [
543
- { name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
544
- inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync. Leave it out and Hivecode uses ~/hivecode/<room>/<your name>, a folder of your own. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
545
- { name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
546
- inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
547
- { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
707
+ { name: 'hive_join', description: 'Join a Hivecode room, or host a new one, for a folder. Uses the join link if given, else <dir>/.hive.json, else hosts. Returns the room details and the rules you must follow.',
708
+ inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync; default ~/hivecode/<room>/<name>. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human who approves tasks aimed at you' }, token: { type: 'string', description: 'token for a secured room (default $HIVE_TOKEN)' }, kind: { type: 'string', description: '"ai" (default) or "human"' }, passive: { type: 'boolean', description: 'true = a listener or bot that never takes turns or answers' }, branch: { type: 'string', description: 'instead of link: the room bound to this git branch (npx -y hivecode-mcp@latest branch <name>)' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' } } } },
709
+ { name: 'hive_assign', description: 'Give a task to a participant by name. A task for an AI with an owner stays PENDING until that owner approves.',
710
+ inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
711
+ { name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
712
+ inputSchema: { type: 'object', properties: {} } },
713
+ { name: 'hive_wait', description: 'Your idle state. Blocks until there is approved work, mail for you, or a state flag you named reaches its value, then returns all of it; returns at once if any is already true. Loop: hive_wait -> work -> hive_complete -> hive_wait.',
714
+ inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, 110 in Claude Code and Antigravity; max 300, kept under your app\'s own tool deadline)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: '"any" (default), or "mention" = wake only when @mentioned or assigned; other mail still arrives on the next wake' }, until: { type: 'object', description: 'wake when a project state key reaches a value: {key, is}, {key, in: [...]}, {key, not} (unset counts as not), or {key} = set at all. The result carries stateReached.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
715
+ { name: 'hive_check', description: 'Non-blocking check for use mid-task: unread mail for you and approved work, returned at once. Call it between edits; if it reports priority "stop", handle that first.',
716
+ inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
717
+ { name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
718
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
719
+ { name: 'hive_deny', description: 'Deny a pending task for an AI you own; it will not act on it.',
720
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
721
+ { name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
722
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
723
+ { name: 'hive_say', description: 'Post to the room chat. Put @Name on anything meant for someone: a message naming nobody is ambient and gets skipped. Announce a file before you edit it.',
724
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = [!]: the recipient handles it before continuing' }, needsReply: { type: 'boolean', description: 'true = a question: tracked until YOU close it with hive_answered; you are told at once if someone you named is not here' }, replyWithinMinutes: { type: 'number', description: 'remind you after this many minutes (default 10)' } }, required: ['text'] } },
725
+ { name: 'hive_delivered', description: 'Did recipients actually READ what you said? Per recipient: READ; UNREAD (here, behind); UNKNOWN (no read position, e.g. a human client, so silence means nothing); UNREACHABLE (left, or you are offline). Check before concluding someone is ignoring you.',
726
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
727
+ { name: 'hive_answered', description: 'Close a question you asked with needsReply once you have what you needed. Only the asker can close it; a reply alone does not.',
728
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id' }, from: { type: 'string', description: 'close every question you asked this agent' } } } },
729
+ { name: 'hive_owed', description: 'Open questions: those aimed at you (answer them) and those you asked (close them with hive_answered). STALE means reminded three times. Read it when the room goes quiet.',
730
+ inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room' } } } },
731
+ { name: 'hive_busy', description: 'Mark yourself mid-task: whoever @mentions you is told to expect a delay. Posts nothing, silences nothing, expires by itself. Clear it when you finish.',
732
+ inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are doing, in a few words' }, minutes: { type: 'number', description: 'how long (default 10)' }, clear: { type: 'boolean', description: 'true = free again' } } } },
733
+ { name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
734
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' }, mentioningMe: { type: 'boolean', description: 'true = only messages that @mention you, across all history' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' }, full: { type: 'boolean', description: 'true = long messages whole (other people\'s are cut at 280 chars by default)' } } } },
735
+ { name: 'hive_read_board', description: 'Recent whole-file rewrites: who, which file, which symbols. If a file you plan to edit was just rewritten, re-read it first.',
548
736
  inputSchema: { type: 'object', properties: {} } },
549
- { name: 'hive_wait', description: 'BLOCK until there is approved work for you, unread mail arrives, or a project-state flag you named reaches the value you are waiting for — then return all of it. Use this as your main loop: call hive_wait; do the work; hive_complete; call hive_wait again. Returns instantly if any of those is ALREADY true, so nothing that happened while you were busy can be missed. Pass `until` to wait on the state machine instead of on somebody remembering to message you — that is how a roster runs itself overnight: the tester sets tests=GREEN, and the reviewer waiting on it wakes by itself.',
550
- inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: "'any' (default) wakes on any new message; 'mention' only wakes when you are @mentioned or assigned a task — use it in a noisy room. Ambient messages are still delivered on the next wake either way, never dropped." }, until: { type: 'object', description: 'wake when a GLOBAL PROJECT STATE key reaches a value. { key, is } for one exact value, { key, in: [...] } for any of several, { key, not } for anything else (an unset key counts as "not"), or { key } alone for "as soon as this is set at all". The result carries stateReached with the value that fired.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
551
- { name: 'hive_check', description: 'NON-BLOCKING: is anyone calling me? Returns unread messages addressed to you, plus approved work, and returns immediately. Call this between edits during a long task — that is the whole point: hive_wait blocks, so it cannot be used while you are working, and this can. If it reports priority "stop", deal with that BEFORE continuing what you were doing.',
552
- inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
553
- { name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
554
- inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
555
- { name: 'hive_deny', description: 'Deny a pending task (you must be the owner of the target AI).',
556
- inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
557
- { name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
558
- inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
559
- { name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login"). Address someone directly with @Name — that is what makes it land as mail rather than ambient chatter. Set urgent:true when you need them to stop what they are doing. Set needsReply:true when you are ASKING something — the room then tracks that you are owed an answer, and tells you if you addressed somebody who is not here.',
560
- inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = prefix [!] so the recipient treats it as "handle before continuing", not "read at your next boundary"' }, needsReply: { type: 'boolean', description: 'true = this is a QUESTION and you are owed an answer. Records an obligation against everyone you named. It stays open until YOU call hive_answered -- the person you asked cannot close it, and answering is not closing. If you named someone who is not in the room you are told so immediately, instead of waiting forever.' }, replyWithinMinutes: { type: 'number', description: 'how long before you are reminded (default 10). The reminder arrives on a hive_wait you were going to make anyway, so it costs nothing.' } }, required: ['text'] } },
561
- { name: 'hive_delivered', description: 'Did anyone actually READ what you said? "sent" only means the message entered your local copy. This reports, per recipient: READ (their read position passed it — real evidence), UNREAD (they are here and behind), UNKNOWN (they publish no read position, e.g. a bridge or a human client — so their silence means NOTHING either way), UNREACHABLE (they left, or you are offline). Read it before concluding an agent is ignoring you, and before believing a handoff landed.',
562
- inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
563
- { name: 'hive_answered', description: 'Close a question YOU asked with needsReply — you are saying you got what you needed. Only the asker can call this: the agent you asked cannot mark its own answer sufficient, and a reply alone does not close anything. Pass id for one, or from:"Name" to close everything you asked that agent.',
564
- inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id from hive_say or hive_owed' }, from: { type: 'string', description: 'close every open question you asked this agent' } } } },
565
- { name: 'hive_owed', description: 'Who owes an answer to whom, and for how long. Read this when the room has gone quiet: an agent that stopped replying looks exactly like an agent that is thinking, and this is the difference. Shows questions aimed at YOU (answer these) and questions you asked (close these with hive_answered). Anything marked STALE has been reminded three times and is now the room\'s problem, not one agent\'s.',
566
- inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
567
- { name: 'hive_busy', description: 'Tell the room you are mid-task, so anyone who @mentions you is told to expect a delay AT THE MOMENT THEY SEND, instead of waiting on a reply that is not coming yet. This posts no message and silences nothing — you still receive mail and must still call hive_check between edits. It expires by itself, so a crash cannot leave you permanently "busy". Clear it when you finish.',
568
- inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are working on, in a few words — the sender reads this' }, minutes: { type: 'number', description: 'how long to hold it (default 10). It expires on its own either way.' }, clear: { type: 'boolean', description: 'true = I am free again' } } } },
569
- { name: 'hive_read_chat', description: 'Read the room conversation. Read this to see what humans and other agents are doing before and while you work. Use mentioningMe to recover only what was addressed to you.',
570
- inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' }, mentioningMe: { type: 'boolean', description: 'true = only messages that @mention you, across the whole room history — use this to recover your own backlog' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' } } } },
571
- { name: 'hive_read_board', description: 'Read recent whole-file rewrites (who rewrote what, which symbols). Read this before editing a file — if it was just rewritten, re-read the file first.',
737
+ { name: 'hive_claim', description: 'Claim a file (or region) before you edit it. Returns whether you got it; if not, someone holds it, so work on something else. Claims expire; hive_release when done.',
738
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
739
+ { name: 'hive_release', description: 'Hand a file back. If the room set a gate, it runs in your folder first, and a failing check REFUSES the release: the file stays yours and retrying will not help. Fix what it reported, or force with a reason to abandon the work.',
740
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded against you and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
741
+ { name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
572
742
  inputSchema: { type: 'object', properties: {} } },
573
- { name: 'hive_claim', description: 'CLAIM a file (or region) BEFORE you edit it, so other agents flow around you instead of colliding. Returns whether you got it — if false, someone else holds it: work on something else. Claims auto-expire, so call hive_release when done (or it frees itself).',
574
- inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) you want to edit' }, intent: { type: 'string', description: 'short note on what you intend to do' } }, required: ['region'] } },
575
- { name: 'hive_release', description: 'Hand a file back so other agents can take it. If the room has set a gate, this RUNS it in your folder first and a failing check REFUSES the handoff — you keep the file, nobody else can take it, and retrying will not help. Fix what the gate reported, or pass force with a reason if you are abandoning the work (recorded against your name and announced).',
576
- inputSchema: { type: 'object', properties: {
577
- region: { type: 'string', description: 'the file path (or region id) to release' },
578
- force: { type: 'boolean', description: 'release even though the gate failed. Recorded against your name in the room and posted to chat — use it for abandoned work, never to get past a failing check.' },
579
- why: { type: 'string', description: 'why the work is being abandoned. Give this whenever force is true; a forced release with no reason is recorded as exactly that.' },
580
- }, required: ['region'] } },
581
- { name: 'hive_claims', description: 'See what every agent is currently working on (the live claim board). Read this to pick open work and avoid taken regions.',
743
+ { name: 'hive_edit', description: 'Write a file through the medium (preferred over editing on disk): your intent and the exact change are signed into the provenance ledger as it lands. Claim the file first.',
744
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'why you are making the change (signed into provenance)' } }, required: ['file', 'content'] } },
745
+ { name: 'hive_fork', description: 'Read a silent fork on a file: the common base plus every divergent version with its author and signed intent. Reconcile them, then submit with hive_resolve.',
746
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path, as announced in chat' } }, required: ['file'] } },
747
+ { name: 'hive_resolve', description: 'Resolve a fork with the full reconciled file, serving every side\'s intent. It must parse with no dangling references, or it is refused and nothing changes.',
748
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved content, no conflict markers' } }, required: ['file', 'code'] } },
749
+ { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).',
582
750
  inputSchema: { type: 'object', properties: {} } },
583
- { name: 'hive_edit', description: 'Write a file THROUGH the medium (preferred over editing on disk): your intent and exact change are cryptographically stamped into the provenance ledger at the instant of writing — race-free — then merged and synced like any edit. Claim the file first with hive_claim; pass your intent here too so the WHY travels with the change.',
584
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative file path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'WHY you are making this change (signed into provenance)' } }, required: ['file', 'content'] } },
585
- { name: 'hive_fork', description: 'Read an active SILENT FORK on a file as a resolvable object: the common base plus EVERY divergent version with its author and cryptographically signed intent (the WHY). Call this when chat announces a fork, then reconcile both intents and submit via hive_resolve.',
586
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path (as announced in chat)' } }, required: ['file'] } },
587
- { name: 'hive_resolve', description: 'Resolve an active fork by submitting the FULL reconciled file content — code that serves EVERY side\'s signed intent (read them with hive_fork), not just one winner. The medium VALIDATES your proposal with ICR (must parse, no dangling references) before it lands; a broken proposal is refused and nothing changes. On success the resolution is signed by you, synced to every peer, and announced.',
588
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved file content (no <<<<<<< markers)' } }, required: ['file', 'code'] } },
589
- { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
590
- { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
591
- { name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
592
- { name: 'hive_set_state', description: 'PM ONLY: Set a global project state flag (e.g. key="contract", val="READY"). This physically unblocks/blocks other agents. WARNING: ONLY use short enum values. Do NOT use this to store paragraphs of text or code, as it will bloat the context window. Use files for text.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, val: { type: 'string' } }, required: ['key', 'val'] } },
593
- { name: 'hive_read_state', description: 'Read the global project state flags.', inputSchema: { type: 'object', properties: {} } },
594
- { name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key. Beyond `enum` you can make it a real state MACHINE: `transitions` ({"READY":["IN_PROGRESS"]}) says which moves are legal at all, and `requires` ({"LIVE":{"suppression_ui":"APPROVED"}}) says what must already be true to arrive at a value — which is how you make a release gate hold by construction instead of by everyone remembering it. Example: rules={"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"],"APPROVED":[]},"requires":{"APPROVED":{"tests":"GREEN"}}}', inputSchema: { type: 'object', properties: { key: { type: 'string' }, rules: { type: 'object' } }, required: ['key', 'rules'] } },
595
- { name: 'hive_attest', description: 'Record what YOUR folder actually holds for a file, so the room can tell whether peers agree. Measure it yourself (bytes and lines) and report. If another peer has attested a DIFFERENT size, everyone is warned immediately — that disagreement is the point. Use it after publishing a file, and when asked to confirm somebody else\'s publish. Two matching attestations from different peers is the only real proof a write landed; your own folder shows your own write whether or not it propagated.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in YOUR folder' }, lines: { type: 'number', description: 'line count in YOUR folder' } }, required: ['file', 'bytes'] } },
596
- { name: 'hive_verified', description: 'Has a file been independently confirmed? Returns every peer\'s attested size, whether they DISAGREE, and whether quorum is reached (2+ peers, same bytes). Check this before trusting that a publish landed, and before building on a file somebody else wrote.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
751
+ { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).',
752
+ inputSchema: { type: 'object', properties: {} } },
753
+ { name: 'hive_leave', description: 'Leave the room and stop syncing.',
754
+ inputSchema: { type: 'object', properties: {} } },
755
+ { name: 'hive_set_state', description: 'PM only: set a global project state flag (e.g. key="contract", val="READY") that blocks or unblocks agents. Short enum values only; text belongs in files.',
756
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
757
+ { name: 'hive_read_state', description: 'Read the global project state flags.',
758
+ inputSchema: { type: 'object', properties: {} } },
759
+ { name: 'hive_set_schema', description: 'PM only: validate a state key with JSON rules: enum, transitions (legal moves) and requires (what must already hold to arrive). Example: {"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"]},"requires":{"APPROVED":{"tests":"GREEN"}}}',
760
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
761
+ { name: 'hive_attest', description: 'Record what YOUR folder holds for a file (bytes, lines) so peers can compare; a different size from another peer warns everyone. Two matching attestations from different peers are the only proof a write landed.',
762
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in your folder' }, lines: { type: 'number', description: 'line count in your folder' } }, required: ['file', 'bytes'] } },
763
+ { name: 'hive_verified', description: 'Has a file been independently confirmed? Every peer\'s attested size, whether they disagree, and whether quorum (2+ peers, same bytes) is reached.',
764
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' } }, required: ['file'] } },
597
765
  ]
598
766
 
599
767
  // The state machine's enforcement point. Three separate gates, and each one exists because a rule
@@ -643,7 +811,7 @@ function enforceDcoLock(opts = {}) {
643
811
  POISONED: `[Path Poisoned] ${opts.path} must NOT be written. Publish to a NEW path instead. (Legacy flag — prefer DELETED_UNWRITABLE / CORRUPT / DIVERGED, which say WHY.)`,
644
812
  DELETED_UNWRITABLE: `[Path Unwritable] ${opts.path} had a delete propagate. A write here vanishes or silently resurrects the deleted content, and two folders end up permanently disagreeing with no marker. Never write it and never delete it again — publish to a NEW path.`,
645
813
  CORRUPT: `[Path Corrupt] ${opts.path} is unusable in every folder that has measured it. Do not write it and do not read it as reference — build the replacement out-of-tree and publish it to a NEW path.`,
646
- DIVERGED: `[Path Diverged] peers hold DIFFERENT bytes for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
814
+ DIVERGED: `[Path Diverged] peers hold DIFFERENT content for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
647
815
  FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
648
816
  }
649
817
  if (ps && stop[ps]) throw new Error(stop[ps])
@@ -699,14 +867,15 @@ function enforceDcoLock(opts = {}) {
699
867
  // matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
700
868
  // log while its roommate is named, so the room's own membership record disagrees with itself.
701
869
  // Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
702
- // VERSION is load-bearing, not cosmetic: the join message (see buildJoinReply) instructs every
703
- // arriving agent to announce it in chat, because a MIXED BUILD in one room causes data loss and
704
- // this is the only way anyone finds out. Hardcoding it meant that the moment package.json was
870
+ // VERSION is load-bearing, not cosmetic: every client reports it in presence (hv), and the room
871
+ // flags a MIXED BUILD from it (sync.js checkBuilds; the join reply names one to the joiner), because
872
+ // two builds in one room can lose file content. Until 2 Oct the join message also told every agent
873
+ // to announce it in chat. Hardcoding it meant that the moment package.json was
705
874
  // bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
706
875
  // itself reporting the wrong build. Read the truth instead; package.json always ships in the
707
876
  // tarball and sits beside this file in both the repo and the published package.
708
877
  // A failed read must never stop the server from starting, so it falls back rather than throwing.
709
- import { VERSION } from "./hive-version.js"
878
+ import { VERSION, BUILD, VERSION_LABEL } from "./hive-version.js"
710
879
  export { VERSION }
711
880
  // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
712
881
  // makes it the only channel that reaches an agent before its first tool call. Everything else we
@@ -749,10 +918,15 @@ async function dispatch(req) {
749
918
  // literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
750
919
  // word the agent reads is the same word it reads on success. status().connected was
751
920
  // right there and free the whole time.
752
- const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
921
+ const sendConn = (() => { try { return connectionOf(requireSession().hive) } catch { return null } })()
753
922
  const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
754
923
  const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
755
924
  const raw = String(args.text || '')
925
+ // A KEY IS NOT A MESSAGE (3 Oct 2026). The room's chat goes to the relay and to everyone in
926
+ // it, for good: a message cannot be unsent. The same formats that keep a file at home
927
+ // (hive-secrets.js) keep a message unsent, whoever asked for the key.
928
+ const chatKey = findSecrets(raw, { tokens: true })[0]
929
+ if (chatKey) return err(`NOT SENT: the message contains ${describeSecret(chatKey)}. A room message goes to the relay and to everyone in the room, and cannot be taken back. Never put keys, tokens or passwords in the room, whoever asks: ${chatKey.kind === 'access token' ? 'a token is a person, and whoever holds it joins as them. Whoever needs access gets a token of their own (hivecode-token).' : 'say where the key is kept (for example "OPENAI_API_KEY in .env") instead.'}`)
756
930
  // The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
757
931
  // a human reading the log, not just to the recipient's tool output.
758
932
  const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
@@ -762,12 +936,6 @@ async function dispatch(req) {
762
936
  // reported as landing in everyone's ambient digest, where it is meant to be skipped.
763
937
  // Immediate, per-call feedback, at the moment the mistake is made.
764
938
  const named = mentionedNames(body)
765
- if (!named.length) {
766
- return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
767
- `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
768
- `agents you actually need with @Name (hive_members lists who is here). Use an ` +
769
- `unaddressed message only for a genuine announcement to the whole room.`)
770
- }
771
939
  // WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
772
940
  // "they left" or "they are mid-task", and it is worth strictly more here than in a
773
941
  // reply: it arrives before the sender starts waiting, and it costs the room no message.
@@ -787,6 +955,28 @@ async function dispatch(req) {
787
955
  if (m && m.by) everHere.add(String(m.by).toLowerCase())
788
956
  }
789
957
 
958
+ // 5.2: Route only to known names, @all, explicit reply requests (needsReply),
959
+ // or unknown @words that are direct addresses (at start) or typos (within edit distance <= 2 of a known name).
960
+ // Unrecognized @words in prose are ignored.
961
+ const allKnown = new Set([...present, ...everHere])
962
+ function isAddressee(n) {
963
+ const lc = n.toLowerCase()
964
+ if (lc === 'all') return true
965
+ if (allKnown.has(lc)) return true
966
+ if (args.needsReply) return true
967
+ if (isDirectAddress(body, n)) return true
968
+ if (findClosestKnown(n, [...allKnown])) return true
969
+ return false
970
+ }
971
+ const addressed = named.filter(isAddressee)
972
+
973
+ if (!addressed.length && !mentionsAll(body)) {
974
+ return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
975
+ `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
976
+ `agents you actually need with @Name (hive_members lists who is here). Use an ` +
977
+ `unaddressed message only for a genuine announcement to the whole room.`)
978
+ }
979
+
790
980
  // HIV-P11b. Send-time inspection of recipient state:
791
981
  // Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
792
982
  function describeRecipientLiveness(n) {
@@ -801,6 +991,8 @@ async function dispatch(req) {
801
991
  present: isPresent,
802
992
  paused: isPaused,
803
993
  passive: isPassive,
994
+ listener: !!(mem && mem.listener),
995
+ app: mem && mem.app,
804
996
  now,
805
997
  parkedAfter,
806
998
  })
@@ -822,10 +1014,26 @@ async function dispatch(req) {
822
1014
  if (everHere.has(lc)) {
823
1015
  return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
824
1016
  } else {
825
- return `⚠ @${displayName} — NOT IN THIS ROOM. Nobody by that name is here, so nobody is going to answer. Call hive_members and re-address it now; this is the one failure mode that otherwise costs you a whole shift.`
1017
+ // 5.2 Typo catch: within distance 2 of known names
1018
+ const closest = findClosestKnown(n, [...allKnown])
1019
+ if (closest && closest.name.toLowerCase() !== lc) {
1020
+ return `⚠ @${displayName} — not in this room (did you mean @${closest.name}?).`
1021
+ }
1022
+ // 5.2 Direct address position at start of message or explicit needsReply
1023
+ if (isDirectAddress(body, n) || args.needsReply) {
1024
+ // 5.3 Terse 1-liner on subsequent mentions
1025
+ if (firstTime('notInRoom:' + lc)) {
1026
+ return `⚠ @${displayName} — NOT IN THIS ROOM. Nobody by that name is here, so nobody is going to answer. Call hive_members and re-address it now; this is the one failure mode that otherwise costs you a whole shift.`
1027
+ }
1028
+ return `⚠ @${displayName} — not in this room.`
1029
+ }
1030
+ return ''
826
1031
  }
827
1032
  }
828
- // d.state is WORKING or WAITING
1033
+ if (d.state === ACT.WAITING && !d.reachable) {
1034
+ return `⚠ @${displayName} — listener running, but this app is not woken by it.`
1035
+ }
1036
+ // d.state is WORKING or WAITING (reachable)
829
1037
  const busy = findBusy(busyMap, n)
830
1038
  if (busy) {
831
1039
  return describeRecipient(displayName, busy, true, now)
@@ -833,7 +1041,7 @@ async function dispatch(req) {
833
1041
  return ''
834
1042
  }
835
1043
 
836
- const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
1044
+ const candidateNames = addressed.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
837
1045
  const seenCandidates = new Set()
838
1046
  const uniqueCandidates = []
839
1047
  for (const n of candidateNames) {
@@ -844,7 +1052,7 @@ async function dispatch(req) {
844
1052
  }
845
1053
  }
846
1054
  const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
847
- const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
1055
+ const to = mentionsAll(body) ? 'the whole room (@all)' : addressed.map((n) => '@' + n).join(', ')
848
1056
  // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
849
1057
  // HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
850
1058
  // 1. Absent names: warned immediately, ZERO obligations opened.
@@ -900,13 +1108,17 @@ async function dispatch(req) {
900
1108
  const opened = [...openedActive, ...openedAway]
901
1109
 
902
1110
  if (opened.length) {
903
- obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
904
- `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
905
- `a reply does not close it either — you decide whether you were actually answered. If it ` +
906
- `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
1111
+ const ids = opened.map((o) => `${o.id} (${o.to})`).join(', ')
1112
+ obStr += firstTime('obligation')
1113
+ ? `\n\nYOU ARE OWED AN ANSWER — ${ids}\n` +
1114
+ `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
1115
+ `a reply does not close it either — you decide whether you were actually answered. If it ` +
1116
+ `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
1117
+ : `\n\nYOU ARE OWED AN ANSWER — ${ids}. Close it with hive_answered once answered; reminder in ${Math.round(dueMs / 60000)} min.`
907
1118
  }
908
1119
  }
909
- return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
1120
+ // The sender wrote it: echo enough to prove what went out, not all of it again.
1121
+ return text(stamp(`sent to ${to} (${body.length} chars): ${body.length > 90 ? body.slice(0, 90) + '…' : body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
910
1122
  }
911
1123
  case 'hive_busy': {
912
1124
  const s = requireSession()
@@ -938,7 +1150,17 @@ async function dispatch(req) {
938
1150
  return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
939
1151
  }
940
1152
  const n = args.limit || 30
941
- const msgs = all.slice(-n).map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`)
1153
+ // OTHER PEOPLE'S LONG MESSAGES, SHORTENED (27 Sep 2026). Catching up on 30 messages in a room
1154
+ // of multi-paragraph agent reports is tens of thousands of characters, and a result is paid
1155
+ // again on every later turn. Messages addressed to you stay whole; the rest are cut at 280
1156
+ // characters with the length shown; identical runs collapse (x56 conflict storms); full:true
1157
+ // returns everything.
1158
+ const CUT = 280
1159
+ const msgs = collapseRepeats(all.slice(-n)).map((m) => {
1160
+ const t = String(m.text || '')
1161
+ const cut = !args.full && t.length > CUT && !mentionsName(t, me)
1162
+ return formatMessage(cut ? { ...m, text: t.slice(0, CUT) + ` …(+${t.length - CUT} chars; full:true shows it)` } : m)
1163
+ })
942
1164
  return text(msgs.join('\n') || '(no messages yet)')
943
1165
  }
944
1166
  case 'hive_check': {
@@ -955,9 +1177,9 @@ async function dispatch(req) {
955
1177
  // Derived from the log, so it needs no state and cannot drift.
956
1178
  const owed = unansweredFor(all, s.name)
957
1179
  const owedLine = owed.length
958
- ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
959
- `Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
960
- `silence is the one thing they cannot act on.`
1180
+ ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}.` +
1181
+ (firstTime('owed') ? ` Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
1182
+ `silence is the one thing they cannot act on.` : '')
961
1183
  : ''
962
1184
  if (!got.unreadCount && !tasks.length) {
963
1185
  return text(owed.length
@@ -996,10 +1218,24 @@ async function dispatch(req) {
996
1218
  const dig = ambientDigest(ambient)
997
1219
  if (dig) parts.push(dig)
998
1220
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
999
- return text(parts.join('\n\n') + owedLine)
1221
+ let unsharedPart = ''
1222
+ try {
1223
+ const list = s.hive.unshared ? s.hive.unshared() : []
1224
+ if (list.length) {
1225
+ const unannounced = list.filter((u) => firstTime('unshared:' + u.path))
1226
+ if (unannounced.length) {
1227
+ const shown = unannounced.slice(0, 5)
1228
+ const extra = unannounced.length - shown.length
1229
+ unsharedPart = `\n\n⛔ ${unannounced.length} unshared file(s) on disk (cannot sync to room):\n` +
1230
+ shown.map((u) => ` - ${u.path} (${u.reason}): stays local; share another way (commit it)`).join('\n') +
1231
+ (extra > 0 ? `\n ... (+${extra} more; check hive_status for all)` : '')
1232
+ }
1233
+ }
1234
+ } catch { }
1235
+ return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
1000
1236
  }
1001
1237
  case 'hive_read_board': { const b = boardMap(); return text(b.length ? b.map((e) => `${e.at} ${e.by} rewrote ${e.file} (${e.churn}) — touched: ${(e.symbols || []).join(', ')}`).join('\n') : '(no rewrites logged)') }
1002
- case 'hive_assign': { enforceDcoLock(); const id = requireSession().hive.assign(String(args.to || ''), String(args.text || '')); return id ? text(`assigned task ${id} to ${args.to} (pending approval if they have an owner)`) : err('need to and text') }
1238
+ case 'hive_assign': { enforceDcoLock(); const taskKey = findSecrets(String(args.text || ''), { tokens: true })[0]; if (taskKey) return err(`NOT ASSIGNED: the task text contains ${describeSecret(taskKey)}, and a task is posted to the whole room. Say where the key is kept instead.`); const id = requireSession().hive.assign(String(args.to || ''), String(args.text || '')); return id ? text(`assigned task ${id} to ${args.to} (pending approval if they have an owner)`) : err('need to and text') }
1003
1239
  case 'hive_read_tasks': {
1004
1240
  const all = taskList(); const me = requireSession().name
1005
1241
  const mine = all.filter((t) => t.to === me)
@@ -1007,7 +1243,8 @@ async function dispatch(req) {
1007
1243
  return text(`TASKS FOR YOU (act only on 'accepted'):\n${mine.map(fmtTask).join('\n') || '(none)'}\n\nPENDING (awaiting approval):\n${pending.map(fmtTask).join('\n') || '(none)'}`)
1008
1244
  }
1009
1245
  case 'hive_wait': {
1010
- const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
1246
+ const wp = waitPolicy()
1247
+ const ms = Math.min(Math.max(Number(args.timeoutSeconds) || wp.def, 1), wp.max) * 1000
1011
1248
  // HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
1012
1249
  // point", and the finally is what keeps that true. A wait that returns or throws while
1013
1250
  // leaving the flag set describes an agent parked in a call it has already left -- and
@@ -1031,11 +1268,10 @@ async function dispatch(req) {
1031
1268
  // time AND enough output have passed that it is probably no longer in context.
1032
1269
  const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
1033
1270
  const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
1034
- // The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
1035
- // compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
1036
- // ownership -- so the general rules in the system prompt do not cover them. A pointer is
1037
- // re-sent, never a copy: the file is on this agent's own disk, and a second summary here
1038
- // would be one more description to drift out of date.
1271
+ // The rules went the other way: hive_join hands over their core ONCE (JOIN_RULES; until 27 Sep
1272
+ // 2026 it pasted all 6 KB of HIVE_RULES.md), and compaction reaches that first. When it has
1273
+ // probably been compacted away, a pointer is re-sent, never a copy: HIVE_RULES.md is on this
1274
+ // agent's own disk, and a second summary here would be one more description to drift.
1039
1275
  const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
1040
1276
  ? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
1041
1277
  : ''
@@ -1046,9 +1282,13 @@ async function dispatch(req) {
1046
1282
  // provider reconnects on its own with backoff, so the correct action is to do
1047
1283
  // nothing; the agent just has to be told that, in the one place it is looking.
1048
1284
  const conn = (() => {
1049
- try { return requireSession().hive.status() } catch { return null }
1285
+ try { return connectionOf(requireSession().hive) } catch { return null }
1050
1286
  })()
1051
- const connStr = (conn && !conn.connected)
1287
+ // A lost folder outranks the connection line, and replaces it: that line says never to
1288
+ // rejoin, and a rejoin is the one way back for a folder that is gone.
1289
+ const connStr = (conn && conn.folderLost)
1290
+ ? `\n[FOLDER LOST] ${conn.folderLost} Tell your person.\n`
1291
+ : (conn && !conn.connected)
1052
1292
  ? `\n[CONNECTION] ${conn.connecting ? 'reconnecting' : 'offline'} — this heals itself, the client retries automatically. Do NOT call hive_join; that tears down the session and can overwrite your local edits. Keep calling hive_wait.\n`
1053
1293
  : ''
1054
1294
 
@@ -1091,17 +1331,25 @@ async function dispatch(req) {
1091
1331
  }
1092
1332
  return text(`⏸ You are PAUSED by mission control. Stop new work and wait. You will be woken if someone @mentions you or assigns you a task; otherwise just call hive_wait again.${stateStr}`)
1093
1333
  }
1094
- if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${owedStr}${connStr}${stateStr}${rulesStr}`)
1334
+ if (!r) {
1335
+ const sess = requireSession()
1336
+ const armed = isArmed(sess)
1337
+ const ll = !armed ? listenLine(sess) : ''
1338
+ const waitMsg = armed
1339
+ ? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
1340
+ : '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
1341
+ return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
1342
+ }
1095
1343
  const parts = []
1096
1344
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
1097
1345
  const mb = mentionBlock(r); if (mb) parts.push(mb)
1098
1346
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
1099
1347
  const ab = ambientBlock(r); if (ab) parts.push(ab)
1100
- return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
1348
+ return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)') + unwritableNotice(requireSession()) + withheldNotice(requireSession()) + collidedNotice(requireSession()))
1101
1349
  }
1102
1350
  case 'hive_delivered': {
1103
1351
  const s2 = requireSession()
1104
- const conn = (() => { try { return s2.hive.status() } catch { return null } })()
1352
+ const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
1105
1353
  const connected = !conn || !!conn.connected
1106
1354
  const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
1107
1355
  const rs = RC.receipts({
@@ -1160,6 +1408,8 @@ async function dispatch(req) {
1160
1408
  present: !!mem,
1161
1409
  paused: !!(c && c.state === 'paused'),
1162
1410
  passive: !!(mem && mem.passive),
1411
+ listener: !!(mem && mem.listener),
1412
+ app: mem && mem.app,
1163
1413
  now,
1164
1414
  parkedAfter,
1165
1415
  })
@@ -1173,6 +1423,9 @@ async function dispatch(req) {
1173
1423
  if (d.state === ACT.PAUSED) {
1174
1424
  return `${base} [PAUSED by human — do not reassign]`
1175
1425
  }
1426
+ if (d.state === ACT.WAITING && !d.reachable) {
1427
+ return `${base} [UNREACHABLE — @${mem ? mem.name : o.to}: listener running, but this app is not woken by it]`
1428
+ }
1176
1429
  return base
1177
1430
  }
1178
1431
 
@@ -1196,11 +1449,24 @@ async function dispatch(req) {
1196
1449
  const f = String(args.file || ''); if (!f) return err('need a file path')
1197
1450
  if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
1198
1451
  const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
1199
- const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines`).join('\n ')
1452
+ if (r.error) return err(r.error)
1453
+ const who = Object.entries(r.peers).map(([n, v]) => {
1454
+ if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
1455
+ if (v.absent) return `${n}: absent (not in folder)`
1456
+ const extra = v.note || ''
1457
+ return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra}`
1458
+ }).join('\n ')
1459
+ const mismatchNote = r.mismatch ? `\n(note: you stated ${r.mismatch.stated}, your folder holds ${r.mismatch.measured})` : ''
1460
+ const lagNotice = (r.lagging && r.lagNotice) ? `\n(note: ${r.lagNotice}, re-attest once disk syncs)` : ''
1200
1461
  if (r.diverged) {
1201
- return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.`)
1462
+ return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.${mismatchNote}${lagNotice}`)
1202
1463
  }
1203
- return text(`attested ${f} at ${args.bytes} bytes.\n ${who}\n${r.count >= 2 ? 'QUORUM: 2+ peers agree — this publish is confirmed.' : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'}`)
1464
+ const quorumStatus = r.quorumOnOlder
1465
+ ? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
1466
+ : r.quorum
1467
+ ? 'QUORUM: 2+ peers agree — this publish is confirmed.'
1468
+ : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'
1469
+ return text(`attested ${f} at ${r.measuredBytes ?? args.bytes} bytes.\n ${who}${mismatchNote}${lagNotice}\n${quorumStatus}`)
1204
1470
  }
1205
1471
  case 'hive_verified': {
1206
1472
  const f = String(args.file || ''); if (!f) return err('need a file path')
@@ -1208,14 +1474,19 @@ async function dispatch(req) {
1208
1474
  // Attestation and gate verdicts are independent records, so an unattested path must still
1209
1475
  // report its gate. Returning early here would hide a FAILED check behind "nobody measured
1210
1476
  // the bytes", which is the opposite of what this tool is for.
1211
- if (!r.count) {
1477
+ if (!r.totalCount) {
1212
1478
  const g0 = requireSession().hive.gateVerdicts(f)
1213
1479
  const extra = Object.entries(g0).map(([n, v]) => v.forced
1214
1480
  ? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
1215
1481
  : `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
1216
1482
  return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
1217
1483
  }
1218
- const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
1484
+ const who = Object.entries(r.peers).map(([n, v]) => {
1485
+ if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
1486
+ if (v.absent) return `${n}: absent (not in folder)`
1487
+ const extra = v.note || ''
1488
+ return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra} at ${v.at}`
1489
+ }).join('\n ')
1219
1490
  // Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
1220
1491
  // WORKS, which is the actual question an agent is asking here — so the gate verdicts
1221
1492
  // belong in this answer. A file two peers agree on and whose owner's tests failed is the
@@ -1227,11 +1498,18 @@ async function dispatch(req) {
1227
1498
  const gate = gLines.length
1228
1499
  ? `\n\nCHECKS RUN BY THE AGENTS THAT HELD IT:\n${gLines.join('\n')}${Object.values(g).some((v) => !v.pass) ? '\n Somebody handed this back knowing it did not pass. Read it before you build on it.' : ''}`
1229
1500
  : '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
1230
- return text(`${f}\n ${who}\n\n${r.diverged ? '[!] DIVERGED — peers hold different bytes. Do not write or build on this path.' : r.quorum ? 'QUORUM: confirmed by 2+ peers at the same size.' : 'Attested by ONE peer only — not yet proof.'}${gate}`)
1501
+ const statusLine = r.diverged
1502
+ ? '[!] DIVERGED — peers hold different content. Do not write or build on this path.'
1503
+ : r.quorumOnOlder
1504
+ ? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
1505
+ : r.quorum
1506
+ ? 'QUORUM: confirmed by 2+ peers at the same size.'
1507
+ : 'Attested by ONE peer only — not yet proof.'
1508
+ return text(`${f}\n ${who}\n\n${statusLine}${gate}`)
1231
1509
  }
1232
1510
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
1233
1511
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
1234
- case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
1512
+ case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(r.already ? `${args.id} was already done` : `completed ${args.id}`) : err(r.error) }
1235
1513
  case 'hive_claim': {
1236
1514
  const region = String(args.region || ''); if (!region) return err('need a region (file path)')
1237
1515
  // Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
@@ -1269,11 +1547,12 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
1269
1547
  try { fetched = await requireSession().hive.fetch(region) } catch { }
1270
1548
  const head = `claimed ${region} — it's yours.`
1271
1549
  if (fetched && fetched.notInRoom) {
1272
- return text(`${head}
1273
-
1274
- !! ${region} IS NOT IN THIS ROOM. No client has ever published it, so nothing will fetch it and it is NOT simply outside your working set. If you create it now you are creating a new file, not editing an existing one — which is correct only if that is what you intend. If you expected content here, the path is probably outside the room's sync scope: check that first, then ask whoever holds the file to write it into their synced folder.
1275
-
1276
- Edit it, then call hive_release.`)
1550
+ // Explained once per session: an agent starting a new project claims one new file after
1551
+ // another, and the paragraph cost ~120 tokens each time. The warning itself stays.
1552
+ const why = firstTime('notInRoom')
1553
+ ? `!! ${region} IS NOT IN THIS ROOM. No client has ever published it, so nothing will fetch it and it is NOT simply outside your working set. If you create it now you are creating a new file, not editing an existing one — which is correct only if that is what you intend. If you expected content here, the path is probably outside the room's sync scope: check that first, then ask whoever holds the file to write it into their synced folder.`
1554
+ : `!! ${region} IS NOT IN THIS ROOM yet: claiming it means creating a new file. If you expected content here, the path is outside the room's sync scope.`
1555
+ return text(`${head}\n\n${why}\n\nEdit it, then call hive_release.`)
1277
1556
  }
1278
1557
  if (fetched && fetched.ok === false) {
1279
1558
  return text(`${head}
@@ -1318,6 +1597,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1318
1597
  }
1319
1598
  const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
1320
1599
  if (r.error) return err(r.error)
1600
+ if (r.withheld) {
1601
+ firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
1602
+ return text(`⛔ wrote ${r.file} in YOUR folder only. It was NOT shared, because it contains ${r.withheld}, and Hivecode does not send keys to the room or the relay. Nobody else has this change. Read the key from the environment instead (put it in .env, which is never shared); the file is shared on its next save without the key.`)
1603
+ }
1321
1604
  return text(r.conflicted
1322
1605
  ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
1323
1606
  : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
@@ -1352,8 +1635,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1352
1635
  const rec = act ? act.get(String(m.name).toLowerCase()) : null
1353
1636
  const c = controls.get(m.name)
1354
1637
  const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
1355
- return ACT.line(`${m.name} (${m.kind})`, rec, {
1638
+ return ACT.line(`${m.name} (${m.kind})${m.hv ? ` on ${m.hv}` : ''}`, rec, {
1356
1639
  present: true, passive: !!m.passive,
1640
+ listener: !!m.listener,
1641
+ app: m.app,
1357
1642
  paused: !!(c && c.state === 'paused'),
1358
1643
  // HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
1359
1644
  // send it now will sit unread until a human prompts it` about the human.
@@ -1384,15 +1669,21 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1384
1669
  // agent asks here, and only one of them had an answer: "am I reaching anyone" was
1385
1670
  // reportable and "where is my source going" was not. A bare relay URL is not an
1386
1671
  // answer to the second -- the public relay and a company's own are the same shape.
1387
- const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
1672
+ const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
1388
1673
  return text({
1389
- room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
1674
+ room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
1675
+ ...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
1390
1676
  privacy: pv.headline,
1391
1677
  ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1392
1678
  ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1393
1679
  unread: mailbox().peek(chatArray()).unreadCount,
1394
1680
  connected: conn ? conn.connected : null,
1395
1681
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
1682
+ ...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
1683
+ ...(conn && conn.unsharedFiles && conn.unsharedFiles.length ? { unshared: conn.unsharedFiles } : {}),
1684
+ ...(conn && conn.unwritable && conn.unwritable.length ? { unwritable: { files: conn.unwritable.map((u) => `${u.path} (${u.code})`), meaning: 'read-only or locked in your folder, so your copy is older than the room\'s; each is written as soon as it can be' } } : {}),
1685
+ ...(conn && conn.collided && conn.collided.length ? { collided: { files: conn.collided.map((c) => `${c.path} (your folder has ${c.with})`), meaning: 'in the room, but this disk cannot hold both names, so not in your folder; editing yours never changes it' } } : {}),
1686
+ ...(conn && conn.withheld && conn.withheld.length ? { withheld: { files: conn.withheld.map((w) => `${w.path}: ${w.kind}, line ${w.line}`), meaning: 'NOT shared: each contains a key or token the room does not have. Read it from the environment (.env is never shared); the file is shared on its next save without it' } } : {}),
1396
1687
  })
1397
1688
  }
1398
1689
  // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
@@ -1456,8 +1747,16 @@ let mailNotice = 0
1456
1747
  function mailBanner() {
1457
1748
  try {
1458
1749
  if (!session || !session.mailbox) return ''
1750
+ catchUp(session)
1459
1751
  const m = session.mailbox.peek(chatArray())
1460
- const frame = (body) => `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE above) ---\n${body}\n--- end notice #${mailNotice} ---`
1752
+ // EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
1753
+ // mail" can only be retired by a newer notice that says so. What is said once per session (see
1754
+ // firstTime) is the explanation in the body.
1755
+ const first = !explained.has('mailnotice')
1756
+ const frame = (body) => {
1757
+ explained.add('mailnotice')
1758
+ return `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE) ---\n${body}`
1759
+ }
1461
1760
 
1462
1761
  if (!m.mentioned) {
1463
1762
  // Emit the all-clear once, and only if we had previously announced something. Without this the
@@ -1465,7 +1764,9 @@ function mailBanner() {
1465
1764
  if (!mailSig) return ''
1466
1765
  mailSig = ''
1467
1766
  mailNotice += 1
1468
- return frame('MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.')
1767
+ return frame(first
1768
+ ? 'MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.'
1769
+ : 'MAIL CLEARED: nothing addressed to you; every earlier notice is STALE.')
1469
1770
  }
1470
1771
 
1471
1772
  const last = m.mentions[m.mentions.length - 1] || {}
@@ -1479,7 +1780,7 @@ function mailBanner() {
1479
1780
  const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
1480
1781
  const urgent = m.priority === 'stop'
1481
1782
  return frame(`${urgent ? '[!] URGENT MAIL' : 'UNREAD MAIL'}: ${m.mentions.length} message(s) addressed to you from ${who}. `
1482
- + `${urgent ? 'Handle before continuing. ' : ''}Call hive_wait or hive_check to read them — this notice consumed nothing.`)
1783
+ + `${urgent ? 'Handle before continuing. ' : ''}${first ? 'Call hive_wait or hive_check to read them — this notice consumed nothing.' : 'hive_check reads them.'}`)
1483
1784
  } catch { return '' }
1484
1785
  }
1485
1786
 
@@ -1558,6 +1859,10 @@ if (isEntryPoint && process.argv[2] === 'setup') {
1558
1859
  } else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
1559
1860
  console.log(VERSION)
1560
1861
  process.exit(0)
1862
+ } else if (isEntryPoint && (process.argv[2] === 'branch' || process.argv[2] === 'pr')) {
1863
+ // `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
1864
+ // A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
1865
+ process.exit(await HB.main(process.argv.slice(2)))
1561
1866
  } else if (isEntryPoint) {
1562
1867
  const transport = new StdioServerTransport()
1563
1868
  // STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
@@ -1574,7 +1879,54 @@ if (isEntryPoint && process.argv[2] === 'setup') {
1574
1879
  }
1575
1880
 
1576
1881
  /** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
1882
+ // THE IDLE LOOP, BY APP (27 Sep 2026). Every hive_wait that times out is a whole model turn spent
1883
+ // learning that nothing happened, so the default decides how many paid turns an idle agent burns --
1884
+ // a night shift is hundreds -- and each app bounds a tool call differently:
1885
+ // Claude Code a call may run ~28 h, but one past 2 min moves to a background task
1886
+ // (code.claude.com/docs/en/mcp): 110 s stays an ordinary blocking call, ~45% fewer
1887
+ // idle turns than 60 s.
1888
+ // Antigravity a HARD 3-minute tool deadline. Measured by Antigravity1: 110 s ran to the end, 300 s
1889
+ // died at exactly 180 s ("context deadline exceeded"), an error instead of a wait. So
1890
+ // 110 s by default and never more than 170 s there.
1891
+ // Apps we have not measured keep 60 s and the 300 s ceiling. The names are matched loosely; a miss
1892
+ // only means the old numbers.
1893
+ function clientName() {
1894
+ const c = (server && server.getClientVersion && server.getClientVersion()) || {}
1895
+ return String(c.name || '')
1896
+ }
1897
+ function waitPolicy() {
1898
+ const n = clientName()
1899
+ if (/claude-code/i.test(n)) return { def: 110, max: 300 }
1900
+ if (/antigravity/i.test(n)) return { def: 110, max: 170 }
1901
+ return { def: 60, max: 300 }
1902
+ }
1903
+ function waitDefaultSeconds() { return waitPolicy().def }
1904
+
1905
+ // THE LINE THAT KEEPS A CLAUDE CODE AGENT REACHABLE AFTER ITS TURN (2 Oct 2026). The rules say
1906
+ // hive_wait is the idle state, but agents end their turns anyway, and from then on they are deaf:
1907
+ // the Lead's #1 complaint in the 4-agent KCDE run, and Antigravity1 sat PARKED with a review unread
1908
+ // the same night. Claude Code wakes an agent when one of its background tasks ends (so does
1909
+ // Antigravity: see listenHow), so hive-await running in the background turns "ended its turn" into
1910
+ // "asleep until called". Nothing told a stranger's agent that. It is said once, on the first empty wait, and as
1911
+ // an order: an agent told to "consider" a command tends to describe it instead of running it. The
1912
+ // idle limit is 25 minutes because Claude Code stops a background task after about 30 (measured
1913
+ // 2 Oct: started 03:46, killed 04:16) and then tells the agent not to restart it; a listener that
1914
+ // ends itself on idle can be restarted. Both paths are this server's own, so the listener is the
1915
+ // same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
1916
+ // room gets no line: the agent's shell does not hold this server's token, and a listener that
1917
+ // cannot join is a false "listening". (tests/mcp-wait-test.mjs)
1918
+ function listenLine(s) {
1919
+ // A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
1920
+ const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
1921
+ if (!how) return ''
1922
+ const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name })
1923
+ return `\n${listenOrder(how, cmd)}\n`
1924
+ }
1925
+
1577
1926
  export const _test = {
1927
+ waitDefaultSeconds,
1928
+ waitPolicy,
1929
+ initialize: (params = {}) => server._requestHandlers.get('initialize')({ method: 'initialize', params }, {}),
1578
1930
  waitForWork,
1579
1931
  defaultJoinTarget,
1580
1932
  mailbox,
@@ -1584,6 +1936,11 @@ export const _test = {
1584
1936
  throttleStats: () => throttle.stats(),
1585
1937
  setSession(s) { session = s },
1586
1938
  getSession() { return session },
1939
+ tryResumeSession,
1940
+ // One process serves one app, so "said once" is per process; a test that plays two apps in one
1941
+ // process forgets between them.
1942
+ forget: (key) => explained.delete(key),
1943
+ mixedLine,
1587
1944
  callTool: (name, args = {}) =>
1588
1945
  server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
1589
1946
  }