hivecode-mcp 0.12.4 → 0.13.0

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
@@ -37,6 +37,8 @@ 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
39
  import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
40
+ import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
41
+ import { isSecuredRoom } from './token.js'
40
42
  import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
41
43
  import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
42
44
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
@@ -48,6 +50,7 @@ import { findSecrets, describeSecret } from './hive-secrets.js' // a key never g
48
50
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
49
51
  import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
50
52
  import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
53
+ import { SESSION_FILE_NAME, readAgents, writeAgents, ownRecord, excludeFromGit } from './hive-session.js'
51
54
 
52
55
  const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
53
56
  let session = null // { hive, room, relay, dir, name, mailbox }
@@ -86,24 +89,44 @@ function resolveRoomRaw(link, dir) {
86
89
  return { relay: DEFAULT_RELAY, room: 'room-' + crypto.randomBytes(13).toString('base64url'), token: '', mode: 'HOSTED (new room)' }
87
90
  }
88
91
 
89
- // Session persistence: keyed to this process's cwd, so each agent runtime (which starts
90
- // its own MCP process from its own directory) resumes only its own identity. Lets the
91
- // session survive MCP host restarts, which otherwise silently drop the agent from the room.
92
- const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
92
+ // Session persistence: in the folder this server was started in, so a restart of the MCP host
93
+ // rejoins as the same agent instead of silently dropping it from the room. That folder is often
94
+ // shared: two Claude Code sessions in one repo is the obvious way to run a branch room. So the file
95
+ // keeps one record per agent, each with this server's pid, and every read takes only this
96
+ // process's own record (hive-session.js says why it once held one agent, and what that broke).
97
+ const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
98
+ const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
99
+ let keptOutOfGit = false
93
100
  function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
94
101
  try {
95
- const prev = readSessionFile() || {}
96
- const chatCursor = cursor === undefined ? prev.chatCursor : cursor
102
+ const agents = readAgents(SESSION_FILE)
103
+ const prev = ownRecord(agents, { conv: CONV })
104
+ const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
97
105
  // `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
98
106
  // tokens -- the agent's shell does not hold this server's token, so that listener could not join.
99
107
  // `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
100
108
  // 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 } : {}) }))
109
+ const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
110
+ ...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
111
+ pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
112
+ for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
113
+ agents[name] = rec
114
+ writeAgents(SESSION_FILE, agents, rec)
115
+ // The launch folder is often the developer's own checkout: keep our files out of their `git status`.
116
+ if (!keptOutOfGit) { keptOutOfGit = true; excludeFromGit(path.dirname(SESSION_FILE)) }
103
117
  } catch { /* best-effort */ }
104
118
  }
105
119
  function readSessionFile() {
106
- try { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) } catch { return null }
120
+ try { return ownRecord(readAgents(SESSION_FILE), { conv: CONV }) } catch { return null }
121
+ }
122
+ /** Take this agent off the record (it left, or was refused), and only this agent. */
123
+ function forgetSession() {
124
+ try {
125
+ const agents = readAgents(SESSION_FILE)
126
+ const mine = ownRecord(agents, { conv: CONV })
127
+ for (const [n, s] of Object.entries(agents)) if (s.pid === process.pid || s === mine) delete agents[n]
128
+ writeAgents(SESSION_FILE, agents)
129
+ } catch { /* best-effort */ }
107
130
  }
108
131
  /** Save the read cursor so a host restart does not reopen the blind window it just closed. */
109
132
  function persistCursor() {
@@ -119,7 +142,7 @@ function persistCursor() {
119
142
  async function tryResumeSession() {
120
143
  try {
121
144
  if (!fs.existsSync(SESSION_FILE)) return
122
- const s = readSessionFile()
145
+ const s = readSessionFile() // only this process's own record: never an agent still running beside it
123
146
  if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
124
147
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
125
148
  }
@@ -166,7 +189,7 @@ function defaultJoinTarget(link, name) {
166
189
  return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
167
190
  }
168
191
 
169
- async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '' }) {
192
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
170
193
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
171
194
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
172
195
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -178,9 +201,26 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
178
201
 
179
202
  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.`
180
203
  }
204
+ // A PERSONAL ROOM IS ALWAYS A NEW ONE (8 Oct 2026, hive-room-key.js). Refused here, before the old
205
+ // session is torn down, for the same reason as the name check above.
206
+ if (personal && (link || branch)) {
207
+ return `private: true hosts a NEW personal room, so it cannot be combined with a ${link ? 'link' : 'branch'}.\n\nNothing was joined and your current session is untouched.`
208
+ }
209
+ if (personal && dir && roomIn(dir)) {
210
+ return `${path.resolve(dir)} already belongs to room ${roomIn(dir)}, and a folder holds one room. Pass a new dir, or leave out private to rejoin that room.\n\nNothing was joined and your current session is untouched.`
211
+ }
181
212
  // A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
182
213
  // this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
183
214
  // session is torn down, for the same reason as the name check above.
215
+ // SINGLE IDENTITY PER MCP PROCESS (Q637). Some host apps (Antigravity, Cursor, Claude Desktop)
216
+ // run ONE shared MCP server process across all their agent windows and tabs. A second join with a
217
+ // different name while a session is live would silently usurp the first session, breaking chat
218
+ // identity and authorship attribution. Refused FIRST, before anything below touches the disk: the
219
+ // branch path marks the folder as not-a-checkout, and a refusal that has already written there is
220
+ // not "nothing was joined" (antigravity-3, 1 Nov).
221
+ if (session && session.name !== name) {
222
+ return `Q637 This Hivecode server is already in use by '${session.name}' in ${session.room} (${session.dir}). If you are a different agent, you are sharing one MCP server with it. Do not leave or rejoin; tell your person: add a second server entry (for example 'hivecode-2') in your app's MCP config and restart the app (docs/JOIN_WITH_AGENT.md). If you ARE '${session.name}' and mean to switch, join again with the name '${session.name}'.\n\nNothing was joined and your current session is untouched.`
223
+ }
184
224
  let branchCtx = null
185
225
  if (branch) {
186
226
  const untouched = '\n\nNothing was joined and your current session is untouched.'
@@ -195,11 +235,20 @@ This is refused rather than fixed for you because renaming silently is worse: if
195
235
  }
196
236
  if (session) { try { session.hive.stop() } catch {} ; session = null }
197
237
  let hostedHere = false
238
+ // The new personal room exists only in memory until the privacy gate below has passed: a refused
239
+ // join must leave nothing behind, and that includes a key for a room nobody ever entered.
240
+ let fresh = null
241
+ if (personal) {
242
+ fresh = newPersonalRoom()
243
+ link = `${DEFAULT_RELAY}|${fresh.room}`
244
+ if (!dir) dir = path.join(HIVE_HOME, fresh.room, safeSegment(name))
245
+ hostedHere = true
246
+ }
198
247
  if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
199
248
  const ROOT = path.resolve(dir)
200
249
  fs.mkdirSync(ROOT, { recursive: true })
201
250
  const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
202
- const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
251
+ const mode = hostedHere ? (fresh ? 'HOSTED (new personal room)' : 'HOSTED (new room)') : resolvedMode
203
252
  // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
204
253
  // .hive.json write below and before startSync opens a socket. A refused join must leave
205
254
  // nothing behind -- no config pointing at the wrong relay for the next process to find,
@@ -218,8 +267,13 @@ folder: ${ROOT}
218
267
  NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
219
268
  was not written. Fix the relay and call hive_join again.`
220
269
  }
270
+ if (fresh) saveRoomKey(fresh, { relay, host: name })
221
271
  // token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
222
- const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
272
+ let useToken = token || linkToken || process.env.HIVE_TOKEN || ''
273
+ // A personal room this machine hosts: the host's own token is minted from the key, and renewed
274
+ // before it runs out, so the host can never be locked out of its own room. Anyone else joining
275
+ // here keeps the token it came with.
276
+ if (!token && isSecuredRoom(room)) useToken = hostToken(room, name, useToken, { kind: kind || 'ai', owner })
223
277
  // persist the token too, so a re-join from .hive.json keeps the grant (the
224
278
  // invitee pastes the link once; subsequent joins just read the folder config).
225
279
  // pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
@@ -333,7 +387,7 @@ was not written. Fix the relay and call hive_join again.`
333
387
  const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
334
388
  try { hive.stop() } catch { }
335
389
  session = null
336
- try { fs.rmSync(SESSION_FILE) } catch { }
390
+ forgetSession()
337
391
  return [
338
392
  `NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
339
393
  'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
@@ -444,10 +498,17 @@ was not written. Fix the relay and call hive_join again.`
444
498
  // What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
445
499
  const browserUrl = controlUrl(inviteLink)
446
500
  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
- : ''
501
+ // A PERSONAL ROOM (hs_ id) is let into by an invite from its host, and nothing else: the plain
502
+ // link below gets a stranger nothing, so it is not offered as the way in.
503
+ const personalRoom = isSecuredRoom(room)
504
+ const ownControlLine = ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : ''
505
+ const tokenNote = personalRoom
506
+ ? (loadRoomKey(room)
507
+ ? `\nPERSONAL ROOM: the relay lets in only people you invite, even on the public relay. It still carries the files, so for code that must never leave your machines, run your own relay. Invite one person at a time (each link works only for them): npx -y hivecode-mcp@latest invite <name> --dir "${ROOT}" (add --reader, or --paths src,docs). Take one back: npx -y hivecode-mcp@latest revoke <name> --dir "${ROOT}"`
508
+ : `\nPERSONAL ROOM: only people its host invites can join. To bring someone in, ask the host for an invite of their own; never pass on yours.`) + ownControlLine
509
+ : useToken
510
+ ? `\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.` + ownControlLine
511
+ : ''
451
512
  // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
452
513
  // who has not run a relay -- the public one and a company's own look identical. The
453
514
  // bracketed verdict is what makes "did my source just leave the building?" answerable
@@ -478,12 +539,13 @@ was not written. Fix the relay and call hive_join again.`
478
539
  // One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
479
540
  // apps as a wall of links).
480
541
  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}` : '')
542
+ const invite = personalRoom ? ''
543
+ : /^HOSTED/.test(mode)
544
+ ? `\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}`
545
+ : (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
484
546
  // No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
485
547
  // (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}`
548
+ 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}`
487
549
  }
488
550
 
489
551
  // A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
@@ -705,7 +767,7 @@ function withheldNotice(s) {
705
767
 
706
768
  const TOOLS = [
707
769
  { 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)' } } } },
770
+ 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' } } } },
709
771
  { 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
772
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
711
773
  { name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
@@ -1674,6 +1736,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1674
1736
  room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
1675
1737
  ...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
1676
1738
  privacy: pv.headline,
1739
+ 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',
1677
1740
  ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1678
1741
  ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1679
1742
  unread: mailbox().peek(chatArray()).unreadCount,
@@ -1689,7 +1752,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1689
1752
  // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1690
1753
  // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1691
1754
  // and diluting it with agents that signed off correctly is how a signal stops being read.
1692
- case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1755
+ case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
1693
1756
  default: return err(`unknown tool: ${name}`)
1694
1757
  }
1695
1758
  } catch (e) { return err(`error: ${e.message}`) }
@@ -1863,6 +1926,28 @@ if (isEntryPoint && process.argv[2] === 'setup') {
1863
1926
  // `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
1864
1927
  // A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
1865
1928
  process.exit(await HB.main(process.argv.slice(2)))
1929
+ } else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
1930
+ // `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
1931
+ // takes it back. Commands rather than tools: see hive-room-key.js for why.
1932
+ process.exit(await roomKeyMain(process.argv.slice(2)))
1933
+ } else if (isEntryPoint && (process.argv[2] === 'github' || process.argv[2] === 'notify' || process.argv[2] === 'slack')) {
1934
+ // `github start|status`: issues labelled hivecode come back as pull requests (hive-github.js).
1935
+ // `notify "<link>"`: a person hears on Slack or Discord when an agent is waiting on them
1936
+ // (hive-notify.js). `slack start "<link>" --channel <id>` or `slack check --channel <id>`:
1937
+ // threads per task in Slack and end-to-end setup check (hive-slack.js).
1938
+ // `slack agent "<link>" --channel <id>`: Slack assistant panel and 1:1 DMs (hive-slack-agent.js).
1939
+ // `slack mcp-server "<link>" --port <port>`: Streamable HTTP MCP server for Slackbot (hive-mcp-http.js).
1940
+ if (process.argv[2] === 'slack' && process.argv[3] === 'agent') {
1941
+ const mod = await import('./hive-slack-agent.js')
1942
+ process.exit(await mod.main(process.argv.slice(4)))
1943
+ } else if (process.argv[2] === 'slack' && process.argv[3] === 'mcp-server') {
1944
+ const mod = await import('./hive-mcp-http.js')
1945
+ process.exit(await mod.main(process.argv.slice(4)))
1946
+ } else {
1947
+ const modPath = process.argv[2] === 'github' ? './hive-github.js' : process.argv[2] === 'slack' ? './hive-slack.js' : './hive-notify.js'
1948
+ const mod = await import(modPath)
1949
+ process.exit(await mod.main(process.argv.slice(3)))
1950
+ }
1866
1951
  } else if (isEntryPoint) {
1867
1952
  const transport = new StdioServerTransport()
1868
1953
  // STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
package/hive-notify.js ADDED
@@ -0,0 +1,233 @@
1
+ // hive-notify -- tell a person, on Slack or Discord, when an agent in a room is waiting on them.
2
+ //
3
+ // npx -y hivecode-mcp@latest notify "<link>" --webhook <url> --for <name>[,<name>] [--every <minutes>] [--title <text>]
4
+ //
5
+ // WHY. On 10 Oct 2026 a two-agent demo stalled for an hour: one agent waited for its person's OK,
6
+ // the other waited for it, and the person was not looking at the chat. The room knew. Nobody was
7
+ // told. An owner who leaves agents working has to hear about the two things only they can unblock:
8
+ //
9
+ // - a task waiting for their approval sent at once: an agent is stopped until they answer
10
+ // - messages from agents naming them batched, at most one post per --every minutes
11
+ //
12
+ // Neither is sent while that person is in the room (the Control Room counts): they can see it.
13
+ //
14
+ // WHAT IS NEVER SENT: the room's link or full id. On an open relay the id is the key to the room's
15
+ // files, and a Slack channel is not the place for a key. Messages name the room by --title, or by
16
+ // the first characters of its id.
17
+ //
18
+ // IT MUST NOT LOOK ALIVE AND DELIVER NOTHING. It posts once when it starts and refuses to run if
19
+ // that post fails; it says once when it has lost the room and once when it is back.
20
+ //
21
+ // Error codes Q640-Q649 are this module's.
22
+
23
+ import fs from 'fs'
24
+ import os from 'os'
25
+ import path from 'path'
26
+ import crypto from 'crypto'
27
+ import { startSync, parseLink } from './sync.js'
28
+ import { makeNotifier, scrub } from './hive-webhook.js'
29
+
30
+ const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
31
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ const LOST_AFTER = 5 * 60000
33
+
34
+ /** Does `text` address `name` (@Name, not part of a longer word)? @all counts. */
35
+ export function addresses(text, name) {
36
+ const t = String(text || '')
37
+ if (/(^|[^\w@])@all\b/i.test(t)) return true
38
+ return new RegExp(`(^|[^\\w@])@${esc(name)}(?![\\w-])`, 'i').test(t)
39
+ }
40
+
41
+ /** What has been sent, kept on disk so a restart does not send it again. */
42
+ export function stateFileFor(room, home = os.homedir()) {
43
+ const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
44
+ return path.join(home, '.hivecode', 'notify', `${h}.json`)
45
+ }
46
+ export function lockFileFor(room, home = os.homedir()) {
47
+ const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
48
+ return path.join(home, '.hivecode', 'notify', `${h}.lock`)
49
+ }
50
+ const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
51
+
52
+ /** One notifier per room. Created exclusively so two starting at the same time cannot double-post. */
53
+ export function takeLock(file) {
54
+ fs.mkdirSync(path.dirname(file), { recursive: true })
55
+ for (let i = 0; i < 2; i++) {
56
+ try {
57
+ const fd = fs.openSync(file, 'wx')
58
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
59
+ fs.closeSync(fd)
60
+ return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === process.pid) fs.unlinkSync(file) } catch { } }
61
+ } catch (e) {
62
+ if (e.code !== 'EEXIST') throw e
63
+ let held = null
64
+ try { held = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { held = null }
65
+ if (held && held.pid && held.pid !== process.pid && alive(held.pid)) {
66
+ const err = new Error(`Q644 a notifier is already running for this room (process ${held.pid}, since ${held.at}). Use that one, or stop it first.`)
67
+ err.code = 'Q644'
68
+ throw err
69
+ }
70
+ try { fs.unlinkSync(file) } catch { }
71
+ }
72
+ }
73
+ const err = new Error(`Q644 could not take ${file}`)
74
+ err.code = 'Q644'
75
+ throw err
76
+ }
77
+ function loadSent(file) {
78
+ try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && j.tasks) return j } catch { }
79
+ return { tasks: {} }
80
+ }
81
+ function saveSent(file, s) {
82
+ try {
83
+ fs.mkdirSync(path.dirname(file), { recursive: true })
84
+ const ids = Object.keys(s.tasks)
85
+ if (ids.length > 500) for (const id of ids.slice(0, ids.length - 500)) delete s.tasks[id]
86
+ fs.writeFileSync(file, JSON.stringify(s))
87
+ } catch { /* a notifier that cannot remember sends twice; it still sends */ }
88
+ }
89
+
90
+ /**
91
+ * The watching itself, on a live sync handle. `check()` is one pass; the CLI calls it on a timer,
92
+ * and tests call it with a clock of their own.
93
+ */
94
+ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(), every = 5, stateFile, log = () => {} }) {
95
+ const people = forNames.map((n) => String(n).trim()).filter(Boolean)
96
+ const lc = new Set(people.map((n) => n.toLowerCase()))
97
+ const tasks = hive.doc.getMap('tasks')
98
+ const owners = hive.doc.getMap('owners')
99
+ const chat = hive.doc.getArray('chat')
100
+ const sent = loadSent(stateFile)
101
+ let seenChat = chat.length // what was said before this started is not news
102
+ const buffer = []
103
+ let lastFlush = 0
104
+ let lostSince = 0
105
+ let toldLost = false
106
+
107
+ const here = (name) => (hive.members ? hive.members() : []).some((m) => String(m.name).toLowerCase() === name.toLowerCase() && m.kind === 'human')
108
+ const ownerOf = (t) => String(owners.get(t.to) || '')
109
+
110
+ function waiting() {
111
+ return [...tasks.values()].filter((t) => t && t.status === 'pending' && lc.has(ownerOf(t).toLowerCase()))
112
+ }
113
+
114
+ async function check() {
115
+ const t = now()
116
+ // THE CONNECTION. Lost for five minutes is said once; back is said once.
117
+ const connected = !!(hive.connection ? hive.connection().connected : hive.provider && hive.provider.wsconnected)
118
+ if (!connected) {
119
+ if (!lostSince) lostSince = t
120
+ if (!toldLost && t - lostSince >= LOST_AFTER) {
121
+ toldLost = true
122
+ await send(`Hivecode lost ${title} ${Math.round((t - lostSince) / 60000)} minutes ago, so nothing from it can reach you here. It keeps trying to reconnect.`)
123
+ }
124
+ return
125
+ }
126
+ if (lostSince) {
127
+ if (toldLost) await send(`Hivecode is back in ${title}.`)
128
+ lostSince = 0
129
+ toldLost = false
130
+ }
131
+
132
+ // A TASK WAITING FOR APPROVAL: at once, once per task.
133
+ for (const task of waiting()) {
134
+ const owner = ownerOf(task)
135
+ if (sent.tasks[task.id] || here(owner)) continue
136
+ const ok = await send(`Approval needed in ${title}: ${task.by} asked ${task.to} to "${oneLine(task.text, 300)}". ${task.to} waits until ${owner} approves or denies it in the Control Room.`)
137
+ if (ok) {
138
+ sent.tasks[task.id] = t
139
+ saveSent(stateFile, sent)
140
+ }
141
+ }
142
+
143
+ // MESSAGES NAMING THEM, from agents, while they are away: batched.
144
+ const all = chat.toArray()
145
+ if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
146
+ for (const m of all.slice(seenChat)) {
147
+ if (!m || m.kind !== 'ai') continue
148
+ for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
149
+ }
150
+ seenChat = all.length
151
+ if (buffer.length && t - lastFlush >= every * 60000) {
152
+ const shown = buffer.slice()
153
+ const to = [...new Set(shown.map((b) => b.to))].join(', ')
154
+ const lines = shown.slice(0, 5).map((b) => `- ${b.by}: ${oneLine(b.text, 220)}`)
155
+ if (shown.length > 5) lines.push(`- and ${shown.length - 5} more`)
156
+ const ok = await send(`${shown.length} message${shown.length === 1 ? '' : 's'} for ${to} in ${title}:\n${lines.join('\n')}`)
157
+ if (ok) {
158
+ buffer.splice(0, shown.length)
159
+ lastFlush = t
160
+ }
161
+ }
162
+ }
163
+
164
+ return { check, waiting, buffered: () => buffer.length, sent }
165
+ }
166
+
167
+ // ---- CLI -------------------------------------------------------------------------------------------
168
+
169
+ const USAGE = `usage:
170
+ npx -y hivecode-mcp@latest notify "<link>" --webhook <Slack or Discord webhook URL> --for <name>[,<name>]
171
+
172
+ --for <names> whose approvals and mentions to send (the people, not their agents)
173
+ --every <min> at most one post of messages per this many minutes (default 5)
174
+ --title <text> what to call the room in posts (default: the first characters of its id)
175
+ --name <name> the notifier's own name in the room (default Notifier)`
176
+
177
+ export async function main(argv, { log = console.log, logErr = console.error, fetchImpl = globalThis.fetch, home = os.homedir(), now = () => Date.now(), once = false } = {}) {
178
+ if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { log(USAGE); return 0 }
179
+ const SWITCHES = new Set(['dry-run', 'once'])
180
+ const pos = []
181
+ const f = {}
182
+ for (let i = 0; i < argv.length; i++) {
183
+ const a = argv[i]
184
+ if (a.startsWith('--')) {
185
+ const k = a.slice(2)
186
+ if (SWITCHES.has(k)) f[k] = true
187
+ else f[k] = argv[++i] ?? ''
188
+ } else {
189
+ pos.push(a)
190
+ }
191
+ }
192
+ const link = pos[0] || ''
193
+ const { relay, room, token } = parseLink(link)
194
+ if (!relay || !room) { logErr(`Q640 "${link}" is not a room link (it looks like wss://relay|room-...).\n\n${USAGE}`); return 2 }
195
+ if (!f.webhook) { logErr(`Q641 say where to post, with --webhook <Slack or Discord incoming-webhook URL>.\n\n${USAGE}`); return 2 }
196
+ const forNames = String(f.for || '').split(',').map((s) => s.trim()).filter(Boolean)
197
+ if (!forNames.length) { logErr(`Q642 say whose waits to report, with --for <name> (the person, as they appear in the room).\n\n${USAGE}`); return 2 }
198
+ const every = Number(f.every || 5)
199
+ if (!(every > 0)) { logErr('Q642 --every must be a positive number of minutes'); return 2 }
200
+ const title = f.title ? oneLine(f.title, 60) : `room ${room.slice(0, 8)}…`
201
+ const send = makeNotifier(f.webhook, { fetchImpl, log })
202
+
203
+ // FIRST, PROVE THE WEBHOOK WORKS. A notifier whose posts go nowhere is worse than none: the person
204
+ // believes they will be told.
205
+ if (send.kind === 'other' && send.host) log(`posting in Slack format to ${send.host}`)
206
+ const okSent = await send(`Hivecode will post here when an agent in ${title} is waiting on ${forNames.join(', ')}.`)
207
+ if (!okSent) { logErr('Q643 the webhook refused the first post (see above). Check the URL, then start again. Nothing is running.'); return 1 }
208
+
209
+ const lockFile = lockFileFor(room, home)
210
+ const releaseLock = (() => { try { return takeLock(lockFile) } catch (e) { logErr(e.message); return null } })()
211
+ if (!releaseLock) return 2
212
+
213
+ const dir = path.join(home, '.hivecode', 'notify', `room-${crypto.createHash('sha256').update(room).digest('hex').slice(0, 12)}`)
214
+ const hive = startSync({ relay, room, dir, name: f.name || 'Notifier', kind: 'ai', token: token || process.env.HIVE_TOKEN || '', syncFiles: false, passive: true, log: () => { } })
215
+ const w = makeWatch({ hive, forNames, title, send, now, every, stateFile: stateFileFor(room, home), log })
216
+ let synced = false
217
+ await new Promise((res) => { const t = setTimeout(res, 15000); hive.provider.on('sync', (s) => { if (s) { synced = true; clearTimeout(t); res() } }) })
218
+ if (!synced && !(hive.provider && hive.provider.wsconnected)) {
219
+ logErr(`NOTE: could not connect to relay at ${relay}. The room may be unreachable or the network down.`)
220
+ }
221
+ const members = (hive.members() || []).filter((m) => m.name !== (f.name || 'Notifier')).map((m) => `${m.name} (${m.kind})`)
222
+ const pending = w.waiting().length
223
+ log(`watching ${title}: ${members.length ? members.join(', ') : 'nobody here yet'}; ${pending} approval(s) waiting now. Ctrl+C stops it.`)
224
+ if (!members.length && !hive.doc.getArray('chat').length) log('NOTE: this room is empty -- no members and no chat. Is the link right?')
225
+ if (once || f['dry-run']) { await w.check(); hive.stop(); releaseLock(); return 0 }
226
+ const timer = setInterval(() => { w.check().catch((e) => log(`check failed: ${e.message}`)) }, 15000)
227
+ const quit = () => { clearInterval(timer); try { hive.stop() } catch { } try { releaseLock() } catch { } process.exit(0) }
228
+ process.on('SIGINT', quit)
229
+ process.on('SIGTERM', quit)
230
+ await w.check()
231
+ return new Promise(() => { }) // runs until stopped
232
+ }
233
+
package/hive-pr.js CHANGED
@@ -37,6 +37,7 @@ import { spawnSync } from 'child_process'
37
37
  import { fileURLToPath } from 'url'
38
38
  import { startSync, parseLink } from './sync.js'
39
39
  import { accountFor, verdict, explain, DIVERGED } from './hive-pr-staleness.js'
40
+ import { contentHash } from './substrate.js'
40
41
 
41
42
  const argv = process.argv.slice(2)
42
43
  const [LINK = '', NAME = 'hive-pr'] = argv.filter((a) => !a.startsWith('--'))
@@ -76,12 +77,22 @@ function gitRaw(args, opts = {}) {
76
77
 
77
78
  /** The authors of a file, newest-landed first, from VERIFIED receipts only.
78
79
  * An unverifiable receipt is not evidence of authorship, so it is dropped rather than trusted. */
79
- export function authorsOf(receipts, verify) {
80
+ // `roomText`, when given: the author of what the room HOLDS, not of the newest receipt. After a
81
+ // control-room revert the room holds an older version, restored without a receipt, so "newest" named
82
+ // the reverted agent as the author of the code it had just been reverted out of (antigravity-3, 1 Nov).
83
+ export function authorsOf(receipts, verify, roomText) {
80
84
  const ok = receipts.filter((r) => r && r.author && (!verify || verify(r)))
81
85
  if (!ok.length) return { last: null, contributors: [], first: 0 }
82
- const last = ok[ok.length - 1]
86
+ // Receipts newer than the one the room holds are work that was reverted out: not co-authors either.
87
+ // Content no receipt matches (a merge, or a revert to a merged state) keeps the old rule, newest
88
+ // lander, a known limit until merged states are stamped into the ledger (antigravity-3, 1 Nov).
89
+ const want = typeof roomText === 'string' ? contentHash(roomText) : null
90
+ let idx = -1
91
+ if (want) for (let i = ok.length - 1; i >= 0; i--) if (ok[i].contentHash === want) { idx = i; break }
92
+ const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
93
+ const last = held[held.length - 1]
83
94
  const seen = new Map()
84
- for (const r of ok) if (!seen.has(r.author)) seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
95
+ for (const r of held) if (!seen.has(r.author)) seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
85
96
  return {
86
97
  last: { author: last.author, name: last.name || 'agent', at: last.at || 0 },
87
98
  contributors: [...seen.values()],
@@ -206,7 +217,9 @@ if (isMain) {
206
217
  const receipts = hive.provenanceOf(rel) || []
207
218
  const audit = hive.verifyProvenanceOf(rel) || { bad: [] }
208
219
  const badIdx = new Set((audit.bad || []).map((b) => b.i))
209
- const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)))
220
+ let roomText = null
221
+ try { roomText = fs.readFileSync(path.join(dir, rel), 'utf8') } catch { }
222
+ const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)), roomText)
210
223
  files.push({ rel, abs: path.join(dir, rel), ...a })
211
224
  }
212
225