hivecode-mcp 0.14.2 → 0.14.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
@@ -41,12 +41,13 @@ import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from '.
41
41
  import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
42
42
  import { isSecuredRoom } from './token.js'
43
43
  import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
44
- import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
44
+ import { INSTRUCTIONS, joinCard, REACH_BY_WAIT } from './hive-instructions.js'
45
45
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
46
46
  import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
47
47
  import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
48
48
  import * as OB from './hive-obligations.js'
49
49
  import * as RC from './hive-receipts.js'
50
+ import * as SEEN from './hive-seen.js' // read state by Yjs id, never by position (0.14.4)
50
51
  import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
51
52
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
52
53
  import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
@@ -100,16 +101,19 @@ function resolveRoomRaw(link, dir) {
100
101
  const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
101
102
  const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
102
103
  let keptOutOfGit = false
103
- function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
104
+ function persistSession({ dir, name, owner, cursor, frontier, authed = false, kind = 'ai', passive = false }) {
104
105
  try {
105
106
  const agents = readAgents(SESSION_FILE)
106
107
  const prev = ownRecord(agents, { conv: CONV })
107
108
  const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
109
+ // The read FRONTIER (hive-seen.js) is what a resume restores; chatCursor stays beside it so an
110
+ // older build resuming from this file still has the count it understands.
111
+ const chatFrontier = frontier === undefined ? (prev && prev.name === name ? prev.chatFrontier : undefined) : (frontier || undefined)
108
112
  // `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
109
113
  // tokens -- the agent's shell does not hold this server's token, so that listener could not join.
110
114
  // `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
111
115
  // AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
112
- const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
116
+ const rec = { dir, name, owner, chatCursor, ...(chatFrontier ? { chatFrontier } : {}), ...(authed ? { authed: true } : {}),
113
117
  ...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
114
118
  pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
115
119
  for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
@@ -134,7 +138,21 @@ function forgetSession() {
134
138
  /** Save the read cursor so a host restart does not reopen the blind window it just closed. */
135
139
  function persistCursor() {
136
140
  if (!session || !session.mailbox) return
137
- persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, authed: session.authed, kind: session.kind, passive: session.passive })
141
+ const chat = session.hive.doc.getArray('chat')
142
+ const frontier = session.mailbox.frontier({ prune: chat })
143
+ persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, frontier, authed: session.authed, kind: session.kind, passive: session.passive })
144
+ // FRONTIERS, NOT A COUNT (0.14.4). What is published is which messages this agent read, by Yjs id,
145
+ // so a message that integrated behind its position is not reported READ. Published only when the
146
+ // frontier really moved: this runs on every hive_wait and hive_check, and an unchanged write still
147
+ // wakes every observer in the room (Claude-cloud's review, attack 4).
148
+ if (frontier && session.mailbox.version !== session.publishedVersion) {
149
+ try {
150
+ RC.publishFrontier(session.hive.doc.getMap('cursors'), session.name, { read: frontier, at: SEEN.coveredPrefix(chat, frontier) })
151
+ session.publishedVersion = session.mailbox.version
152
+ } catch { /* a room that predates receipts */ }
153
+ return
154
+ }
155
+ if (frontier) return
138
156
  // AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
139
157
  // what makes "did they actually read it" answerable by anybody other than me. The cursor only
140
158
  // advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
@@ -192,7 +210,7 @@ function defaultJoinTarget(link, name) {
192
210
  return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
193
211
  }
194
212
 
195
- async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
213
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, chatFrontier, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
196
214
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
197
215
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
198
216
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -411,6 +429,10 @@ was not written. Fix the relay and call hive_join again.`
411
429
  'it reports `connected` and `filesSynced`, and both are authoritative.',
412
430
  ].join('\n')
413
431
  }
432
+ // HIV-C3: wait for presence to settle before evaluating sameNameElsewhere (Incident I-3).
433
+ // Waits for the first awareness update from the relay, or 1500 ms, whichever comes first.
434
+ await settlePresence(hive, 1500)
435
+
414
436
  // ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
415
437
  // a name already here is the same identity to everybody: both are told they hold each other's
416
438
  // claims, both read each other's mail, and their writes land on each other's files. Measured
@@ -440,8 +462,31 @@ was not written. Fix the relay and call hive_join again.`
440
462
  // process was down is still delivered rather than falling into the old blind window.
441
463
  const chatLen = hive.doc.getArray('chat').length
442
464
  const startAt = Number.isFinite(Number(chatCursor)) ? Number(chatCursor) : chatLen
443
- session.mailbox = createMailbox({ me: name, startAt })
465
+ session.mailbox = createMailbox({ me: name, startAt, frontier: chatFrontier && typeof chatFrontier === 'object' ? chatFrontier : null })
444
466
  persistCursor()
467
+ session.joined = Date.now()
468
+
469
+ // Twin join re-check (0.14.4 C3): if two case-variant names joined at the same moment both see nobody.
470
+ // Re-check once more about 3 s after joining. If a twin has appeared, the LATER joiner
471
+ // (higher join time) gets one line telling it to rejoin under another name.
472
+ setTimeout(() => {
473
+ try {
474
+ if (!session || session.name !== name || session.room !== room) return
475
+ const dupesLater = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : []
476
+ if (!dupesLater.length) return
477
+ const d = dupesLater[0]
478
+ const myJoined = session.joined || 0
479
+ const theirJoined = d.joined || 0
480
+ const myId = hive.provider && hive.provider.awareness ? hive.provider.awareness.clientID : 0
481
+ const isLater = theirJoined < myJoined || (theirJoined === myJoined && (d.clientID || 0) < myId)
482
+ if (isLater) {
483
+ const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
484
+ let n = 2
485
+ while (taken.has(`${name}-${n}`.toLowerCase())) n++
486
+ session.twinWarning = `A twin named "${d.name}" joined at the same moment. You are the later joiner: call hive_join with a different name to rejoin under another name (e.g. hive_join { name: "${name}-${n}" }).`
487
+ }
488
+ } catch { }
489
+ }, 3000).unref?.()
445
490
 
446
491
  const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
447
492
  const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
@@ -488,7 +533,7 @@ was not written. Fix the relay and call hive_join again.`
488
533
  const s = session
489
534
  if (!s || !s.mailbox) return
490
535
  catchUp(s)
491
- const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
536
+ const m = newMailOnly(s.mailbox.peek(hive.doc.getArray('chat')))
492
537
  // Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
493
538
  // 0, and a later single mention must still ring. `>` would go permanently silent.
494
539
  if (!m.mentioned || m.mentions.length === lastMailSignal) return
@@ -525,7 +570,7 @@ was not written. Fix the relay and call hive_join again.`
525
570
  // THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
526
571
  // session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
527
572
  // the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
528
- const rulesText = JOIN_RULES
573
+ const rulesText = joinCard(reachLine(session))
529
574
  // A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
530
575
  // as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
531
576
  // and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
@@ -585,7 +630,8 @@ was not written. Fix the relay and call hive_join again.`
585
630
  : (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
586
631
  // No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
587
632
  // (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
588
- return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\n${personalRoom ? 'room link (lets nobody in without an invite)' : 'invite link'}: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
633
+ const ls = listenerStatusFor({ name, room, useToken, passive })
634
+ return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nlistener: ${ls.card}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\n${personalRoom ? 'room link (lets nobody in without an invite)' : 'invite link'}: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
589
635
  }
590
636
 
591
637
  // A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
@@ -657,12 +703,29 @@ function mailbox() {
657
703
  // redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
658
704
  // whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
659
705
  // a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
706
+ //
707
+ // HANDED IS NOT READ (0.14.4, Claude-cloud's attack 6, incident I-1). The listener used to publish this
708
+ // agent's READ cursor when it woke on a mention, and this seeked the mailbox past it. An app that was
709
+ // never woken (Antigravity), or crashed before its turn, then never got that mail on its next session,
710
+ // and hive_delivered told the sender READ. Now the room record carries two frontiers: `read` (moved
711
+ // only by this agent's own hive_wait / hive_check) is restored here; `handed` (moved by the listener
712
+ // too) only marks a mention as already handed -- it is delivered once more, tagged, never dropped.
713
+ // A record from an older build has only a count, `at`, and can't say which of the two it was, so
714
+ // it is taken as HANDED: at worst one mention is shown twice, never lost.
660
715
  function catchUp(s) {
661
716
  if (!s || !s.mailbox) return
662
717
  try {
718
+ const chat = s.hive.doc.getArray('chat')
663
719
  const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
664
- const at = (c && Number(c.at)) || 0
665
- if (at > s.mailbox.cursor && at <= s.hive.doc.getArray('chat').length) s.mailbox.seek(at)
720
+ if (!c) return
721
+ const when = c.handedAt || (c.ts ? new Date(Number(c.ts)).toTimeString().slice(0, 5) : '')
722
+ if (c.ver >= 2 && c.read) {
723
+ s.mailbox.seedFrontier(c.read, chat)
724
+ if (c.handed) s.mailbox.handedBy(c.handed, when)
725
+ return
726
+ }
727
+ const at = Number(c.at) || 0
728
+ if (at > 0 && at <= chat.length) s.mailbox.handedBy(SEEN.frontierOfPrefix(chat, at).toJSON(), when)
666
729
  } catch { /* a room that predates receipts */ }
667
730
  }
668
731
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
@@ -723,7 +786,7 @@ function waitForWork(timeoutMs, opts = {}) {
723
786
  // `baseChatLen` here and returned `chat.slice(baseChatLen)`, which meant anything posted
724
787
  // between one wait returning and the next one starting — the whole time the agent was
725
788
  // working — was returned by no path at all. Two paths also hardcoded `newChat: []`.
726
- const deliver = (extra) => { const got = box.take(chat.toArray()); persistCursor(); return { ...got, ...extra } }
789
+ const deliver = (extra) => { const got = box.take(chat); persistCursor(); return { ...got, ...extra } } // the Y.Array: read by id
727
790
  // Only wake for ambient chatter if asked to. Defaults to waking on anything, as before.
728
791
  const wakeOnMentionOnly = opts.wakeOn === 'mention'
729
792
  const mentionsMe = (t) => mentionsName(t, me)
@@ -745,7 +808,7 @@ function waitForWork(timeoutMs, opts = {}) {
745
808
  if (dt.length) return finish(deliver({ paused: true, mentioned: true, tasks: dt }))
746
809
  // A paused agent stays deaf to chatter but wakes when directly addressed. Unchanged,
747
810
  // except the messages come from the mailbox now, so none are skipped.
748
- if (box.peek(chat.toArray()).mentioned) finish(deliver({ paused: true, tasks: [] }))
811
+ if (box.peek(chat).mentioned) finish(deliver({ paused: true, tasks: [] }))
749
812
  }
750
813
  const onCtl = check, onChat = check, onTask = check
751
814
  controls.observe(onCtl); chat.observe(onChat); tmap.observe(onTask)
@@ -765,8 +828,8 @@ function waitForWork(timeoutMs, opts = {}) {
765
828
  if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
766
829
  // Anything already unread is handed over immediately rather than waited past — that backlog
767
830
  // is exactly what used to be unreachable.
768
- if (box.hasUnread(chat.toArray())) {
769
- const pending = box.peek(chat.toArray())
831
+ if (box.hasUnread(chat)) {
832
+ const pending = box.peek(chat)
770
833
  if (!wakeOnMentionOnly || pending.mentioned) return Promise.resolve(deliver({ tasks: [] }))
771
834
  }
772
835
  return new Promise((resolve) => {
@@ -774,7 +837,7 @@ function waitForWork(timeoutMs, opts = {}) {
774
837
  const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); if (until) { try { state.unobserve(onState) } catch { /* never observed */ } } resolve(v) } }
775
838
  const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
776
839
  const onChat = () => {
777
- const seen = box.peek(chat.toArray())
840
+ const seen = box.peek(chat)
778
841
  if (!seen.unreadCount) return // only this agent's own messages — not mail
779
842
  if (wakeOnMentionOnly && !seen.mentioned) return // ambient chatter, and the caller opted out
780
843
  finish(deliver({ tasks: accepted() }))
@@ -787,7 +850,7 @@ function waitForWork(timeoutMs, opts = {}) {
787
850
  tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
788
851
  // On timeout, flush anything that arrived rather than dropping it: a message must never
789
852
  // expire unread just because the wait window closed on it.
790
- setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
853
+ setTimeout(() => finish(box.hasUnread(chat) ? deliver({ tasks: accepted() }) : null), timeoutMs)
791
854
  })
792
855
  }
793
856
 
@@ -828,6 +891,48 @@ function collidedNotice(s) {
828
891
  } catch { return '' }
829
892
  }
830
893
 
894
+ function twinNotice(s) {
895
+ if (!s || !s.twinWarning) return ''
896
+ return `\n\n⚠ ${s.twinWarning}`
897
+ }
898
+
899
+ async function settlePresence(hive, timeoutMs = 1500) {
900
+ if (hive && hive.provider && hive.provider.awareness && typeof hive.sameNameElsewhere === 'function' && !hive.sameNameElsewhere().length) {
901
+ await new Promise((resolve) => {
902
+ let timer = null
903
+ let done = false
904
+ const finish = () => {
905
+ if (!done) {
906
+ done = true
907
+ clearTimeout(timer)
908
+ try { hive.provider.awareness.off('change', onChange) } catch { }
909
+ resolve()
910
+ }
911
+ }
912
+ const onChange = (_changes, origin) => {
913
+ if (origin !== 'local') finish()
914
+ }
915
+ timer = setTimeout(finish, timeoutMs)
916
+ hive.provider.awareness.on('change', onChange)
917
+ })
918
+ }
919
+ }
920
+
921
+ function listenerStatusFor({ session: s, name, room, useToken, passive } = {}) {
922
+ const targetName = name || (s && s.name)
923
+ const targetRoom = room || (s && s.room)
924
+ const isPassive = passive !== undefined ? !!passive : !!(s && s.passive)
925
+ const isAuthed = useToken !== undefined ? !!useToken : !!(s && s.authed)
926
+ const how = !isAuthed && !isPassive ? listenHow(clientName()) : ''
927
+ if (!how) return { enabled: false, status: 'not used on this client', card: 'idle with hive_wait' }
928
+ const armed = isArmed({ name: targetName, room: targetRoom })
929
+ return {
930
+ enabled: true,
931
+ status: armed ? 'running' : 'not running',
932
+ card: armed ? 'running' : 'not running',
933
+ }
934
+ }
935
+
831
936
  // A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
832
937
  // is usually the one that needs telling, and the room line about it is in that agent's own name.
833
938
  function withheldNotice(s) {
@@ -844,13 +949,13 @@ function withheldNotice(s) {
844
949
 
845
950
  const TOOLS = [
846
951
  { 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.',
847
- 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)' }, private: { type: 'boolean', description: 'host a NEW room only people you invite can join' } } } },
952
+ 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' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' }, private: { type: 'boolean', description: 'host a NEW room only people you invite can join' } } } },
848
953
  { 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.',
849
954
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
850
955
  { name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
851
956
  inputSchema: { type: 'object', properties: {} } },
852
- { 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.',
853
- 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'] } } } },
957
+ { name: 'hive_wait', description: '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. Idle with it on clients that have no listener (hive_join says which).',
958
+ inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60; 110 in Claude Code and Antigravity; max 300)' }, 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'] } } } },
854
959
  { 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.',
855
960
  inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
856
961
  { name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
@@ -861,8 +966,8 @@ const TOOLS = [
861
966
  inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'manifest files to review' }, verdict: { type: 'string', enum: ['pass', 'fail'], description: 'review verdict: pass or fail' }, note: { type: 'string', description: 'review notes explaining verdict' } }, required: ['files', 'verdict'] } },
862
967
  { name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
863
968
  inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
864
- { 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.',
865
- 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'] } },
969
+ { 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.',
970
+ 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' }, replyWithinMinutes: { type: 'number', description: 'remind you after this many minutes (default 10)' } }, required: ['text'] } },
866
971
  { 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.',
867
972
  inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
868
973
  { 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.',
@@ -872,13 +977,13 @@ const TOOLS = [
872
977
  { 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.',
873
978
  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' } } } },
874
979
  { name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
875
- 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)' } } } },
980
+ 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 (else cut at 280 chars)' } } } },
876
981
  { 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.',
877
982
  inputSchema: { type: 'object', properties: {} } },
878
- { 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.',
983
+ { name: 'hive_claim', description: 'Claim a file (or region) before you edit it (new behaviour others rely on: post its design first); if refused, someone holds it, so do other work. A claim lasts 5 min, renews when you claim again, and holds at most 15 min in a row (then a 15 min wait): release and re-claim between long steps. hive_release when done.',
879
984
  inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
880
- { 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.',
881
- 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'] } },
985
+ { name: 'hive_release', description: 'Hand a file back. A room gate your person approved runs in your folder first; a failing check REFUSES the release: the file stays yours and retrying will not help. Fix it, or force with a reason to abandon the work. An unapproved gate does not run (Q592).',
986
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
882
987
  { name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
883
988
  inputSchema: { type: 'object', properties: {} } },
884
989
  { 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. Changing existing lines: pass old (whole lines, exactly as you read them) and new; if those lines changed since you read them it is refused and nothing is written. A whole new file: pass content.',
@@ -893,11 +998,11 @@ const TOOLS = [
893
998
  inputSchema: { type: 'object', properties: {} } },
894
999
  { name: 'hive_leave', description: 'Leave the room and stop syncing.',
895
1000
  inputSchema: { type: 'object', properties: {} } },
896
- { 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.',
1001
+ { name: 'hive_set_state', description: 'Set a room-wide state flag (e.g. key="contract", val="READY") that blocks or unblocks agents waiting on it. Any member can set one. Short enum values only; text belongs in files.',
897
1002
  inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
898
1003
  { name: 'hive_read_state', description: 'Read the global project state flags.',
899
1004
  inputSchema: { type: 'object', properties: {} } },
900
- { 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"}}}',
1005
+ { name: 'hive_set_schema', description: '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"}}}',
901
1006
  inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
902
1007
  { 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.',
903
1008
  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'] } },
@@ -1291,7 +1396,7 @@ async function dispatch(req) {
1291
1396
  return text(`${mine.length} message(s) addressed to you:\n` + mine.map(formatMessage).join('\n'))
1292
1397
  }
1293
1398
  if (args.unreadOnly) {
1294
- const seen = mailbox().peek(all)
1399
+ const seen = mailbox().peek(requireSession().hive.doc.getArray('chat'))
1295
1400
  if (!seen.unreadCount) return text('(nothing unread)')
1296
1401
  return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
1297
1402
  }
@@ -1313,7 +1418,8 @@ async function dispatch(req) {
1313
1418
  const s = requireSession()
1314
1419
  const all = chatArray()
1315
1420
  const box = mailbox()
1316
- const got = args.peek ? box.peek(all) : box.take(all)
1421
+ const ychat = s.hive.doc.getArray('chat') // the mailbox reads by Yjs id; `all` stays for the plain-array helpers below
1422
+ const got = args.peek ? box.peek(ychat) : box.take(ychat)
1317
1423
  if (!args.peek) persistCursor()
1318
1424
  const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
1319
1425
  // WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
@@ -1357,8 +1463,18 @@ async function dispatch(req) {
1357
1463
  parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
1358
1464
  parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
1359
1465
  } else if (got.mentioned) {
1360
- parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
1361
- parts.push(got.mentions.map(formatMessage).join('\n'))
1466
+ // A mention your listener already handed over is shown ONCE more, on its own line and marked,
1467
+ // never as new mail to answer (A6): if your app was woken with it, it is probably done.
1468
+ const fresh = got.mentions.filter((m) => !m.handed)
1469
+ const handedOver = got.mentions.filter((m) => m.handed)
1470
+ if (fresh.length) {
1471
+ parts.push(`📣 ${fresh.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
1472
+ parts.push(fresh.map(formatMessage).join('\n'))
1473
+ }
1474
+ if (handedOver.length) {
1475
+ parts.push(`↪ ${handedOver.length} already handed to you by your listener (shown once; if you already acted on it, ignore it):`)
1476
+ parts.push(handedOver.map(formatMessage).join('\n'))
1477
+ }
1362
1478
  }
1363
1479
  const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
1364
1480
  const dig = ambientDigest(ambient)
@@ -1378,7 +1494,7 @@ async function dispatch(req) {
1378
1494
  }
1379
1495
  }
1380
1496
  } catch { }
1381
- return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
1497
+ return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s) + twinNotice(s))
1382
1498
  }
1383
1499
  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)') }
1384
1500
  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') }
@@ -1461,9 +1577,15 @@ async function dispatch(req) {
1461
1577
  // Direct address is separated from ambient traffic on every path. It used to be that
1462
1578
  // only a PAUSED agent got this distinction, so a working agent could not tell "stop,
1463
1579
  // wrong file" from two other agents talking to each other.
1464
- const mentionBlock = (r) => (r.mentions && r.mentions.length)
1465
- ? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
1466
- : ''
1580
+ // A mention your listener already handed over is shown once more, marked, apart from new mail (A6).
1581
+ const mentionBlock = (r) => {
1582
+ const all = r.mentions || []
1583
+ const fresh = all.filter((m) => !m.handed), handedOver = all.filter((m) => m.handed)
1584
+ const blocks = []
1585
+ if (fresh.length) blocks.push(`${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${fresh.map(formatMessage).join('\n')}`)
1586
+ if (handedOver.length) blocks.push(`${handedOver.some((m) => priorityOf(m.text) === 'stop') ? '🛑 URGENT, already handed to you by your listener — if you have not acted on it, do now' : '↪ already handed to you by your listener (shown once; if you already acted on it, ignore it)'}:\n${handedOver.map(formatMessage).join('\n')}`)
1587
+ return blocks.join('\n\n')
1588
+ }
1467
1589
  // Both delivery paths go through the SAME digest. They used to format ambient traffic
1468
1590
  // separately, which is how two copies of a rule drift apart -- the exact hazard that
1469
1591
  // already bit the duplicated reconcile() and the two HIVE_RULES copies.
@@ -1484,22 +1606,25 @@ async function dispatch(req) {
1484
1606
  const waitMsg = armed
1485
1607
  ? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
1486
1608
  : '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
1487
- return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
1609
+ return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${twinNotice(sess)}${stateStr}${rulesStr}${ll}`)
1488
1610
  }
1489
1611
  const parts = []
1490
1612
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
1491
1613
  const mb = mentionBlock(r); if (mb) parts.push(mb)
1492
1614
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
1493
1615
  const ab = ambientBlock(r); if (ab) parts.push(ab)
1494
- 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()))
1616
+ 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()) + twinNotice(requireSession()))
1495
1617
  }
1496
1618
  case 'hive_delivered': {
1497
1619
  const s2 = requireSession()
1498
1620
  const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
1499
1621
  const connected = !conn || !!conn.connected
1500
1622
  const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
1623
+ const ych = s2.hive.doc.getArray('chat')
1624
+ const its = SEEN.itemsOf(ych)
1501
1625
  const rs = RC.receipts({
1502
- chat: s2.hive.doc.getArray('chat').toArray(),
1626
+ chat: its.map((x) => x.value),
1627
+ ids: its.map((x) => x.id), // so a message behind someone's position is UNREAD, not a false READ
1503
1628
  cursors: s2.hive.doc.getMap('cursors'),
1504
1629
  me: s2.name,
1505
1630
  present: (n) => here.has(String(n).toLowerCase()),
@@ -1737,6 +1862,7 @@ Then call hive_release.`)
1737
1862
  const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
1738
1863
  if (r && r.ok === false) return text(r.message)
1739
1864
  const check = await releaseCheck(requireSession(), region)
1865
+ if (r && r.gateSkipped) return text(`released ${region}, NOT CHECKED\n${r.message}${check}`)
1740
1866
  if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room${check}`)
1741
1867
  return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}${check}`)
1742
1868
  }
@@ -1779,9 +1905,14 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1779
1905
  firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
1780
1906
  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.`)
1781
1907
  }
1782
- return text(r.conflicted
1783
- ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
1784
- : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
1908
+ if (r.conflicted) return text(`wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`)
1909
+ // SAY WHETHER IT REACHED THE RELAY, never just "syncing" (0.14.3 P0-1). Over HTTPS long-poll an
1910
+ // edit waits in this process until the relay accepts it; an agent that left on "syncing" lost it.
1911
+ const hiveS = requireSession().hive
1912
+ const sent = typeof hiveS.whenSent === 'function' ? await hiveS.whenSent(r.file, 3000) : true
1913
+ return text(sent
1914
+ ? `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the relay has the change; every peer gets it from there.`
1915
+ : `⏳ wrote ${r.file} in your folder, but the relay has NOT accepted the change yet (slow or reconnecting link). It is still being sent: stay in the room (do not exit or leave) and check hive_status before you report it done.`)
1785
1916
  }
1786
1917
  case 'hive_fork': {
1787
1918
  const file = String(args.file || ''); if (!file) return err('need a file path')
@@ -1848,16 +1979,19 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1848
1979
  // reportable and "where is my source going" was not. A bare relay URL is not an
1849
1980
  // answer to the second -- the public relay and a company's own are the same shape.
1850
1981
  const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
1982
+ const ls = listenerStatusFor({ session: s })
1851
1983
  return text({
1852
1984
  room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
1853
1985
  ...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
1986
+ listener: ls.status,
1854
1987
  privacy: pv.headline,
1855
1988
  access: isSecuredRoom(s.room) ? (loadRoomKey(s.room) ? 'personal: invite only, and you host it (npx -y hivecode-mcp@latest invite <name>)' : 'personal: invite only') : 'open: anyone with the link can join',
1856
1989
  ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1857
1990
  ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1858
- unread: mailbox().peek(chatArray()).unreadCount,
1991
+ unread: mailbox().peek(requireSession().hive.doc.getArray('chat')).unreadCount,
1859
1992
  connected: conn ? conn.connected : null,
1860
1993
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
1994
+ ...(s.twinWarning ? { twinWarning: s.twinWarning } : {}),
1861
1995
  // How full this client's bounded working set is: files open / the bound (HIVE_MAX_OPEN_FILES, or
1862
1996
  // 4096 on one socket, 128 with a socket per file). At the bound, files that were in the room
1863
1997
  // at join arrive when touched; new ones still open (sync.js newRemote), and `waiting` counts
@@ -1870,13 +2004,26 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1870
2004
  ...(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' } } : {}),
1871
2005
  ...(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' } } : {}),
1872
2006
  ...(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' } } : {}),
2007
+ // What only THIS folder needed to know, kept off the room's chat (0.14.4, C5): a local copy kept after
2008
+ // a room write, a file that differed at join, a teammate on the same release from another commit.
2009
+ ...((() => { try { const n = s.hive.notices ? s.hive.notices() : []; return n.length ? { thisFolderOnly: n.slice(-8).map((x) => `${new Date(x.at).toISOString().slice(11, 19)}Z ${x.text}`) } : {} } catch { return {} } })()),
1873
2010
  })
1874
2011
  }
1875
2012
  // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1876
2013
  // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1877
2014
  // and diluting it with agents that signed off correctly is how a signal stops being read.
1878
2015
  case 'hive_pr': return text(await runHivePr(args))
1879
- case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
2016
+ case 'hive_leave': {
2017
+ try { ACT.forget(activityMap(), requireSession().name) } catch { }
2018
+ const h = requireSession().hive
2019
+ // Leave waits until the relay has every edit and message (0.14.3 P0-1), and names what it did not get.
2020
+ const r = typeof h.leave === 'function' ? await h.leave({ timeoutMs: 10000 }) : (h.stop(), null)
2021
+ session = null; forgetSession()
2022
+ const missed = r ? [...r.unsent, ...(r.roomUnsent ? ['your last room messages'] : [])] : []
2023
+ return missed.length
2024
+ ? text(`left the room, but the relay had NOT accepted these when it stopped waiting (10 s): ${missed.join(', ')}. Nobody else has those changes. Rejoin and check them before you report the work done.`)
2025
+ : text('left the room')
2026
+ }
1880
2027
  default: return err(`unknown tool: ${name}`)
1881
2028
  }
1882
2029
  } catch (e) { return err(`error: ${e.message}`) }
@@ -1931,11 +2078,19 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1931
2078
  let mailSig = ''
1932
2079
  let mailNotice = 0
1933
2080
 
2081
+ // A mention this agent's listener already handed over is not NEW mail for a notice (A6). hive_wait
2082
+ // and hive_check still deliver it once more, marked, so an app that was never woken still gets it;
2083
+ // a notice on every tool reply would bring back the 19-duplicates storm (listener-mail-once-test).
2084
+ // An URGENT one still rings: a stand-down nobody saw is the worse of the two failures.
2085
+ function newMailOnly(m) {
2086
+ const mentions = (m.mentions || []).filter((x) => !x.handed || priorityOf(x.text) === 'stop')
2087
+ return { ...m, mentions, mentioned: mentions.length > 0 }
2088
+ }
1934
2089
  function mailBanner() {
1935
2090
  try {
1936
2091
  if (!session || !session.mailbox) return ''
1937
2092
  catchUp(session)
1938
- const m = session.mailbox.peek(chatArray())
2093
+ const m = newMailOnly(session.mailbox.peek(session.hive.doc.getArray('chat')))
1939
2094
  // EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
1940
2095
  // mail" can only be retired by a newer notice that says so. What is said once per session (see
1941
2096
  // firstTime) is the explanation in the body.
@@ -2006,9 +2161,37 @@ function missingRequired(req) {
2006
2161
  return lines.join('\n')
2007
2162
  }
2008
2163
 
2164
+ // [!] MEANS "BEFORE YOU CONTINUE", SO IT IS CHECKED BEFORE THE CALL, NOT AFTER IT (P1-8, 9 Nov 2026).
2165
+ // Mail reached an agent only as a notice appended to a tool's RESULT, after the tool had run. In the
2166
+ // 9 Nov mock run Claude sent "[!] @antigravity-VM Stand down on test.js: ... Don't edit it."; the
2167
+ // agent's next call was hive_edit test.js, the file was written, and "[!] URGENT MAIL" came back in
2168
+ // the reply to that very write. The room read it as "delivered to one of two agents". So a call that
2169
+ // writes, takes work or lands it is refused while urgent mail addressed to this agent is unread, and
2170
+ // the refusal shows the message. Reading it (hive_check or hive_wait) is the decision; then retry.
2171
+ // Releasing, talking and reading are never held: a stand-down usually asks for exactly those.
2172
+ const URGENT_GATED = new Set(['hive_edit', 'hive_claim', 'hive_resolve', 'hive_fork', 'hive_pr', 'hive_set_state'])
2173
+ function urgentGate(name) {
2174
+ try {
2175
+ if (!URGENT_GATED.has(name) || !session || !session.mailbox) return ''
2176
+ catchUp(session)
2177
+ const m = session.mailbox.peek(session.hive.doc.getArray('chat'))
2178
+ const urgent = (m.mentions || []).filter((x) => x && priorityOf(x.text) === 'stop')
2179
+ if (!urgent.length) return ''
2180
+ const shown = urgent.slice(-3).map((x) => ` ${formatMessage(x)}`.slice(0, 600)).join('\n')
2181
+ return [
2182
+ `REFUSED -- ${urgent.length} URGENT message(s) ([!]) addressed to you are unread, and [!] means: handle it before you continue.`,
2183
+ `Nothing was done. This ${name} call did NOT happen.`,
2184
+ shown,
2185
+ `Read it with hive_check (that marks it read), do what it asks, then call ${name} again if it still applies.`,
2186
+ ].join('\n')
2187
+ } catch { return '' }
2188
+ }
2189
+
2009
2190
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
2010
2191
  const bad = missingRequired(req)
2011
2192
  if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
2193
+ const held = urgentGate(req.params && req.params.name)
2194
+ if (held) return { content: [{ type: 'text', text: held }], isError: true }
2012
2195
  const res = await dispatch(req)
2013
2196
  // Never let the banner break a tool: append only to a well-formed text result, and skip the two
2014
2197
  // tools that already deliver mail themselves (appending would double-report the same messages).
@@ -2050,6 +2233,11 @@ if (isEntryPoint && process.argv[2] === 'setup') {
2050
2233
  // `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
2051
2234
  // A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
2052
2235
  process.exit(await HB.main(process.argv.slice(2)))
2236
+ } else if (isEntryPoint && process.argv[2] === 'gate') {
2237
+ // `gate allow --dir <folder> --cmd "<exact>"`: a person approves the room's hand-back check for this
2238
+ // machine (0.14.4 S-1). A command, never a tool: an agent persuaded by a message cannot consent.
2239
+ const { main: gateMain } = await import('./hive-gate.js')
2240
+ process.exit(await gateMain(process.argv.slice(2)))
2053
2241
  } else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
2054
2242
  // `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
2055
2243
  // takes it back. Commands rather than tools: see hive-room-key.js for why.
@@ -2078,8 +2266,18 @@ if (isEntryPoint && process.argv[2] === 'setup') {
2078
2266
  // room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
2079
2267
  // and a presence entry for a member that was gone, until something killed it.
2080
2268
  server.onclose = () => {
2081
- try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
2082
- setTimeout(() => process.exit(0), 500)
2269
+ // Leave, not stop: the last edits and messages go out before the process does (0.14.3 P0-1). Over
2270
+ // HTTPS long-poll they are still in this process; a 500 ms exit dropped them. Capped, so a dead
2271
+ // link cannot hold a departed agent's seat for long; whatever did not go is named on stderr.
2272
+ const done = () => process.exit(0)
2273
+ setTimeout(done, 12000).unref()
2274
+ const s = session
2275
+ if (!s) return setTimeout(done, 500)
2276
+ Promise.resolve()
2277
+ .then(() => (typeof s.hive.leave === 'function' ? s.hive.leave({ timeoutMs: 10000 }) : (s.hive.stop(), null)))
2278
+ .then((r) => { if (r && (r.unsent.length || r.roomUnsent)) console.error(`[hive-mcp] left before the relay accepted: ${[...r.unsent, ...(r.roomUnsent ? ['(room messages)'] : [])].join(', ')}`) })
2279
+ .catch(() => { /* leaving is best-effort; exiting is not */ })
2280
+ .finally(() => setTimeout(done, 200))
2083
2281
  }
2084
2282
  await server.connect(transport)
2085
2283
  startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
@@ -2124,6 +2322,16 @@ function waitDefaultSeconds() { return waitPolicy().def }
2124
2322
  // same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
2125
2323
  // room gets no line: the agent's shell does not hold this server's token, and a listener that
2126
2324
  // cannot join is a false "listening". (tests/mcp-wait-test.mjs)
2325
+ // THE JOIN CARD SAYS HOW TO STAY REACHABLE ON THIS CLIENT (0.14.4 rules rewrite). The rules tell
2326
+ // every agent to "do what hive_join told you", so join must always tell it something true: the
2327
+ // listener where the server knows one works, hive_wait everywhere else (other apps, token-checked
2328
+ // rooms, passive participants).
2329
+ function reachLine(s) {
2330
+ const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
2331
+ if (!how) return REACH_BY_WAIT
2332
+ const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name, app: clientName() })
2333
+ return `1. Stay reachable: before your turn ends, start your listener. ${how} It ends, and that wakes you, when you are addressed or after ${LISTEN_IDLE_MIN} quiet minutes; then start it again:\n ${cmd}`
2334
+ }
2127
2335
  function listenLine(s) {
2128
2336
  // A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
2129
2337
  const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
@@ -2151,6 +2359,9 @@ export const _test = {
2151
2359
  // process forgets between them.
2152
2360
  forget: (key) => explained.delete(key),
2153
2361
  mixedLine,
2362
+ listenerStatusFor,
2363
+ settlePresence,
2364
+ joinRoom,
2154
2365
  callTool: (name, args = {}) =>
2155
2366
  server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
2156
2367
  }