hivecode-mcp 0.8.0 → 0.11.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
@@ -22,6 +22,7 @@
22
22
  import fs from 'fs'
23
23
  import path from 'path'
24
24
  import crypto from 'crypto'
25
+ import { fileURLToPath } from 'url'
25
26
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
26
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
27
28
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
@@ -33,9 +34,10 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
33
34
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
34
35
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
35
36
  import { startSync, parseLink } from './sync.js'
37
+ import { createMailbox, formatMessage, mentionsName, priorityOf } from './hive-mailbox.js'
36
38
 
37
39
  const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
38
- let session = null // { hive, room, relay, dir, name }
40
+ let session = null // { hive, room, relay, dir, name, mailbox }
39
41
 
40
42
  const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] })
41
43
  const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
@@ -53,18 +55,30 @@ function resolveRoom(link, dir) {
53
55
  // its own MCP process from its own directory) resumes only its own identity. Lets the
54
56
  // session survive MCP host restarts, which otherwise silently drop the agent from the room.
55
57
  const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
56
- function persistSession({ dir, name, owner }) {
57
- try { fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner })) } catch { /* best-effort */ }
58
+ function persistSession({ dir, name, owner, cursor }) {
59
+ try {
60
+ const prev = readSessionFile() || {}
61
+ const chatCursor = cursor === undefined ? prev.chatCursor : cursor
62
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor }))
63
+ } catch { /* best-effort */ }
64
+ }
65
+ function readSessionFile() {
66
+ try { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) } catch { return null }
67
+ }
68
+ /** Save the read cursor so a host restart does not reopen the blind window it just closed. */
69
+ function persistCursor() {
70
+ if (!session || !session.mailbox) return
71
+ persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
58
72
  }
59
73
  async function tryResumeSession() {
60
74
  try {
61
75
  if (!fs.existsSync(SESSION_FILE)) return
62
- const s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'))
76
+ const s = readSessionFile()
63
77
  if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
64
78
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
65
79
  }
66
80
 
67
- async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '' }) {
81
+ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
68
82
  if (session) { try { session.hive.stop() } catch {} ; session = null }
69
83
  const ROOT = path.resolve(dir)
70
84
  fs.mkdirSync(ROOT, { recursive: true })
@@ -75,7 +89,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
75
89
  // invitee pastes the link once; subsequent joins just read the folder config).
76
90
  fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
77
91
  const hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
78
- session = { hive, room, relay, dir, name }
92
+ session = { hive, room, relay, dir, name, owner, mailbox: null }
79
93
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
80
94
  // wait for the first sync so members/board/rules are ready
81
95
  await new Promise((resolve) => {
@@ -84,8 +98,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
84
98
  hive.provider.on('sync', (s) => s && finish())
85
99
  setTimeout(finish, 8000) // don't hang if the relay is cold
86
100
  })
101
+ // The mailbox is created AFTER the first sync, so chat.length is the real room history and
102
+ // not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
103
+ // A resumed session carries its saved cursor forward instead, so anything posted while this
104
+ // process was down is still delivered rather than falling into the old blind window.
105
+ const chatLen = hive.doc.getArray('chat').length
106
+ const startAt = Number.isFinite(Number(chatCursor)) ? Number(chatCursor) : chatLen
107
+ session.mailbox = createMailbox({ me: name, startAt })
108
+ persistCursor()
109
+
87
110
  const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
88
-
111
+
89
112
  // Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
90
113
  hive.doc.getMap('swarm_state').observe(() => {
91
114
  try {
@@ -94,17 +117,69 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
94
117
  // client might not support notifications, swallow error
95
118
  }
96
119
  })
97
-
120
+
121
+ // MAIL WAKEUPS — the other half of the same channel, and it was missing.
122
+ //
123
+ // The observer above pushes a notification the instant swarm_state changes, so a state flip
124
+ // reaches an agent mid-task. Chat had no such channel: it is observed only INSIDE the
125
+ // hive_wait handler, which means an @mention reached a working agent only if it happened to
126
+ // already be blocked in that call. An agent heads-down in a task is exactly the one that
127
+ // cannot choose to look.
128
+ //
129
+ // Measured on a real four-agent build (JARVIS, 2026-07-28/29): 345 mentions in the room log,
130
+ // agents repeatedly reporting they never saw the one addressed to them, and a PM that ended up
131
+ // writing open work to disk files and a shell watcher because the coordination channel could
132
+ // not be relied on to deliver. Every one of those workarounds exists to replace these lines.
133
+ //
134
+ // This is a DOORBELL, NOT A DELIVERY. It uses mailbox.peek(), so nothing is consumed and
135
+ // hive_wait/hive_check still hand over the full message with their cursor bookkeeping intact.
136
+ // Reusing the mailbox also inherits its two hard-won behaviours for free: an agent is never
137
+ // woken by its own hive_say, and repeat runs are collapsed instead of flooding.
138
+ let lastMailSignal = 0
139
+ hive.doc.getArray('chat').observe(() => {
140
+ try {
141
+ const s = session
142
+ if (!s || !s.mailbox) return
143
+ const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
144
+ // Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
145
+ // 0, and a later single mention must still ring. `>` would go permanently silent.
146
+ if (!m.mentioned || m.mentions.length === lastMailSignal) return
147
+ lastMailSignal = m.mentions.length
148
+ server.notification({
149
+ method: 'notifications/hive_mail',
150
+ params: {
151
+ mentioned: true,
152
+ priority: m.priority, // 'stop' when someone marked it urgent — handle before continuing
153
+ count: m.mentions.length,
154
+ from: [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))],
155
+ latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
156
+ hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
157
+ },
158
+ })
159
+ } catch (e) {
160
+ // client might not support notifications, swallow error
161
+ }
162
+ })
163
+
98
164
  // The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
99
165
  // so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
100
166
  // defined, which made every hive_join return "error: rules is not defined".)
101
167
  let rulesText = ''
102
168
  try { rulesText = fs.readFileSync(path.join(ROOT, 'HIVE_RULES.md'), 'utf8') } catch {}
103
169
  if (!rulesText) rulesText = 'Read HIVE_RULES.md in this folder. In short: read chat + board before editing; CLAIM a file with hive_claim before you edit it and hive_release when done; announce intent with hive_say; prefer small patches; act only on accepted tasks; resolve <<<<<<< conflict markers rather than blindly overwriting.'
104
- return `${mode}\nroom: ${room}\nrelay: ${relay}\nfolder: ${ROOT}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${relay}|${room}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
170
+ // The invite link carries the token when there is one — without it a secured room rejects
171
+ // whoever you hand it to, and the Control Room says the link is missing its token.
172
+ const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
173
+ return `${mode}\nroom: ${room}\nrelay: ${relay}\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
105
174
  }
106
175
 
107
176
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
177
+ /** The read cursor for this participant. Lazily built if a session predates it. */
178
+ function mailbox() {
179
+ const s = requireSession()
180
+ if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
181
+ return s.mailbox
182
+ }
108
183
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
109
184
  function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
110
185
  function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
@@ -113,17 +188,23 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
113
188
  // Block until there is approved work for me (or a new chat message), then return.
114
189
  // This is how the agent reacts the INSTANT the owner approves — no polling loop,
115
190
  // no interval to tune. Resolves immediately if approved work already exists.
116
- function waitForWork(timeoutMs) {
191
+ function waitForWork(timeoutMs, opts = {}) {
117
192
  const s = requireSession()
118
193
  const tmap = s.hive.doc.getMap('tasks')
119
194
  const chat = s.hive.doc.getArray('chat')
120
195
  const controls = s.hive.doc.getMap('controls')
121
196
  const me = s.name
197
+ const box = mailbox()
122
198
  const paused = () => { const c = controls.get(me); return !!(c && c.state === 'paused') }
123
199
  const accepted = () => (paused() ? [] : [...tmap.values()].filter((t) => t.to === me && t.status === 'accepted'))
124
- const baseChatLen = chat.length
125
- const rx = new RegExp('@' + me.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
126
- const mentionsMe = (t) => rx.test(String(t || ''))
200
+ // Deliver through the mailbox on EVERY return path. The old code captured a call-scoped
201
+ // `baseChatLen` here and returned `chat.slice(baseChatLen)`, which meant anything posted
202
+ // between one wait returning and the next one starting — the whole time the agent was
203
+ // working — was returned by no path at all. Two paths also hardcoded `newChat: []`.
204
+ const deliver = (extra) => { const got = box.take(chat.toArray()); persistCursor(); return { ...got, ...extra } }
205
+ // Only wake for ambient chatter if asked to. Defaults to waking on anything, as before.
206
+ const wakeOnMentionOnly = opts.wakeOn === 'mention'
207
+ const mentionsMe = (t) => mentionsName(t, me)
127
208
  // PAUSED: stop auto-working, but STAY LISTENING. A paused agent doesn't pull its
128
209
  // queued work — yet it still wakes the moment it's directly addressed: a new
129
210
  // @mention in chat, or a new task assigned to it. It handles that, then calls
@@ -133,28 +214,47 @@ function waitForWork(timeoutMs) {
133
214
  return new Promise((resolve) => {
134
215
  let done = false
135
216
  const finish = (v) => { if (!done) { done = true; controls.unobserve(onCtl); chat.unobserve(onChat); tmap.unobserve(onTask); resolve(v) } }
136
- const mentions = () => chat.toArray().slice(baseChatLen).filter((m) => mentionsMe(m.text))
137
217
  const directTasks = () => [...tmap.values()].filter((t) => t.to === me && t.status === 'accepted' && !baseTaskIds.has(t.id))
138
218
  const check = () => {
139
- if (!paused()) return finish({ tasks: accepted(), newChat: [], resumed: true })
140
- const dt = directTasks(), mn = mentions()
141
- if (dt.length || mn.length) finish({ paused: true, mentioned: true, tasks: dt, newChat: mn })
219
+ // Resumed: hand over the backlog too. This path returned `newChat: []`, so an agent
220
+ // came back from a pause having silently lost everything said while it was paused.
221
+ if (!paused()) return finish(deliver({ tasks: accepted(), resumed: true }))
222
+ const dt = directTasks()
223
+ if (dt.length) return finish(deliver({ paused: true, mentioned: true, tasks: dt }))
224
+ // A paused agent stays deaf to chatter but wakes when directly addressed. Unchanged,
225
+ // except the messages come from the mailbox now, so none are skipped.
226
+ if (box.peek(chat.toArray()).mentioned) finish(deliver({ paused: true, tasks: [] }))
142
227
  }
143
228
  const onCtl = check, onChat = check, onTask = check
144
229
  controls.observe(onCtl); chat.observe(onChat); tmap.observe(onTask)
145
230
  setTimeout(() => finish({ paused: true }), timeoutMs)
146
231
  })
147
232
  }
233
+ // Queued work no longer hides the mail. This returned `newChat: []` outright, which made
234
+ // the busiest agent in the room the one guaranteed to see no messages at all.
148
235
  const ready = accepted()
149
- if (ready.length) return Promise.resolve({ tasks: ready, newChat: [] })
236
+ if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
237
+ // Anything already unread is handed over immediately rather than waited past — that backlog
238
+ // is exactly what used to be unreachable.
239
+ if (box.hasUnread(chat.toArray())) {
240
+ const pending = box.peek(chat.toArray())
241
+ if (!wakeOnMentionOnly || pending.mentioned) return Promise.resolve(deliver({ tasks: [] }))
242
+ }
150
243
  return new Promise((resolve) => {
151
244
  let done = false
152
245
  const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
153
- const onTask = () => { const a = accepted(); if (a.length) finish({ tasks: a, newChat: [] }) }
154
- const onChat = () => { const extra = chat.toArray().slice(baseChatLen); if (extra.length) finish({ tasks: accepted(), newChat: extra }) }
246
+ const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
247
+ const onChat = () => {
248
+ const seen = box.peek(chat.toArray())
249
+ if (!seen.unreadCount) return // only this agent's own messages — not mail
250
+ if (wakeOnMentionOnly && !seen.mentioned) return // ambient chatter, and the caller opted out
251
+ finish(deliver({ tasks: accepted() }))
252
+ }
155
253
  const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
156
254
  tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl)
157
- setTimeout(() => finish(null), timeoutMs)
255
+ // On timeout, flush anything that arrived rather than dropping it: a message must never
256
+ // expire unread just because the wait window closed on it.
257
+ setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
158
258
  })
159
259
  }
160
260
 
@@ -165,18 +265,20 @@ const TOOLS = [
165
265
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
166
266
  { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
167
267
  inputSchema: { type: 'object', properties: {} } },
168
- { name: 'hive_wait', description: 'BLOCK until there is approved work for you (or new chat), then return it. Use this as your main loop: call hive_wait; when it returns an accepted task, do the work and call hive_complete; then call hive_wait again. Returns instantly if approved work already exists. Times out after timeoutSeconds (default 60) so you can re-call.',
169
- inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' } } } },
268
+ { name: 'hive_wait', description: 'BLOCK until there is approved work for you, or unread mail arrives, then return BOTH. Use this as your main loop: call hive_wait; do the work; hive_complete; call hive_wait again. Returns instantly if approved work or unread mail already exists — nothing addressed to you can be missed while you were busy, because your read position is remembered between calls.',
269
+ inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: "'any' (default) wakes on any new message; 'mention' only wakes when you are @mentioned or assigned a task — use it in a noisy room. Ambient messages are still delivered on the next wake either way, never dropped." } } } },
270
+ { name: 'hive_check', description: 'NON-BLOCKING: is anyone calling me? Returns unread messages addressed to you, plus approved work, and returns immediately. Call this between edits during a long task — that is the whole point: hive_wait blocks, so it cannot be used while you are working, and this can. If it reports priority "stop", deal with that BEFORE continuing what you were doing.',
271
+ inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
170
272
  { name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
171
273
  inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
172
274
  { name: 'hive_deny', description: 'Deny a pending task (you must be the owner of the target AI).',
173
275
  inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
174
276
  { name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
175
277
  inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
176
- { name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login").',
177
- inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' } }, required: ['text'] } },
178
- { name: 'hive_read_chat', description: 'Read the room conversation. Read this to see what humans and other agents are doing before and while you work.',
179
- inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' } } } },
278
+ { name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login"). Address someone directly with @Name — that is what makes it land as mail rather than ambient chatter. Set urgent:true when you need them to stop what they are doing.',
279
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = prefix [!] so the recipient treats it as "handle before continuing", not "read at your next boundary"' } }, required: ['text'] } },
280
+ { name: 'hive_read_chat', description: 'Read the room conversation. Read this to see what humans and other agents are doing before and while you work. Use mentioningMe to recover only what was addressed to you.',
281
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' }, mentioningMe: { type: 'boolean', description: 'true = only messages that @mention you, across the whole room history — use this to recover your own backlog' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' } } } },
180
282
  { name: 'hive_read_board', description: 'Read recent whole-file rewrites (who rewrote what, which symbols). Read this before editing a file — if it was just rewritten, re-read the file first.',
181
283
  inputSchema: { type: 'object', properties: {} } },
182
284
  { name: 'hive_claim', description: 'CLAIM a file (or region) BEFORE you edit it, so other agents flow around you instead of colliding. Returns whether you got it — if false, someone else holds it: work on something else. Claims auto-expire, so call hive_release when done (or it frees itself).',
@@ -185,25 +287,122 @@ const TOOLS = [
185
287
  inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) to release' } }, required: ['region'] } },
186
288
  { name: 'hive_claims', description: 'See what every agent is currently working on (the live claim board). Read this to pick open work and avoid taken regions.',
187
289
  inputSchema: { type: 'object', properties: {} } },
290
+ { name: 'hive_edit', description: 'Write a file THROUGH the medium (preferred over editing on disk): your intent and exact change are cryptographically stamped into the provenance ledger at the instant of writing — race-free — then merged and synced like any edit. Claim the file first with hive_claim; pass your intent here too so the WHY travels with the change.',
291
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative file path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'WHY you are making this change (signed into provenance)' } }, required: ['file', 'content'] } },
292
+ { name: 'hive_fork', description: 'Read an active SILENT FORK on a file as a resolvable object: the common base plus EVERY divergent version with its author and cryptographically signed intent (the WHY). Call this when chat announces a fork, then reconcile both intents and submit via hive_resolve.',
293
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path (as announced in chat)' } }, required: ['file'] } },
294
+ { name: 'hive_resolve', description: 'Resolve an active fork by submitting the FULL reconciled file content — code that serves EVERY side\'s signed intent (read them with hive_fork), not just one winner. The medium VALIDATES your proposal with ICR (must parse, no dangling references) before it lands; a broken proposal is refused and nothing changes. On success the resolution is signed by you, synced to every peer, and announced.',
295
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved file content (no <<<<<<< markers)' } }, required: ['file', 'code'] } },
188
296
  { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
189
297
  { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
190
298
  { name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
191
299
  { name: 'hive_set_state', description: 'PM ONLY: Set a global project state flag (e.g. key="contract", val="READY"). This physically unblocks/blocks other agents. WARNING: ONLY use short enum values. Do NOT use this to store paragraphs of text or code, as it will bloat the context window. Use files for text.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, val: { type: 'string' } }, required: ['key', 'val'] } },
192
300
  { name: 'hive_read_state', description: 'Read the global project state flags.', inputSchema: { type: 'object', properties: {} } },
193
- { name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key to prevent agents from hallucinating values (e.g. key="contract", rules={"enum": ["PLANNING", "READY", "APPROVED"]}).', inputSchema: { type: 'object', properties: { key: { type: 'string' }, rules: { type: 'object' } }, required: ['key', 'rules'] } },
301
+ { name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key. Beyond `enum` you can make it a real state MACHINE: `transitions` ({"READY":["IN_PROGRESS"]}) says which moves are legal at all, and `requires` ({"LIVE":{"suppression_ui":"APPROVED"}}) says what must already be true to arrive at a value — which is how you make a release gate hold by construction instead of by everyone remembering it. Example: rules={"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"],"APPROVED":[]},"requires":{"APPROVED":{"tests":"GREEN"}}}', inputSchema: { type: 'object', properties: { key: { type: 'string' }, rules: { type: 'object' } }, required: ['key', 'rules'] } },
302
+ { name: 'hive_attest', description: 'Record what YOUR folder actually holds for a file, so the room can tell whether peers agree. Measure it yourself (bytes and lines) and report. If another peer has attested a DIFFERENT size, everyone is warned immediately — that disagreement is the point. Use it after publishing a file, and when asked to confirm somebody else\'s publish. Two matching attestations from different peers is the only real proof a write landed; your own folder shows your own write whether or not it propagated.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in YOUR folder' }, lines: { type: 'number', description: 'line count in YOUR folder' } }, required: ['file', 'bytes'] } },
303
+ { name: 'hive_verified', description: 'Has a file been independently confirmed? Returns every peer\'s attested size, whether they DISAGREE, and whether quorum is reached (2+ peers, same bytes). Check this before trusting that a publish landed, and before building on a file somebody else wrote.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
194
304
  ]
195
305
 
196
- function enforceDcoLock() {
197
- const state = requireSession().hive.getState()
198
- const name = requireSession().name
199
- if (state[`${name}_status`] === 'LOCKED' || state[`${name}_locked`] === 'true') {
306
+ // The state machine's enforcement point. Three separate gates, and each one exists because a rule
307
+ // written in prose was read by four capable agents and then broken anyway (JARVIS, 2026-07-29).
308
+ //
309
+ // 1. LOCKED the original gate — you may work, you may not publish
310
+ // 2. PUBLISH:<n> a LEASED grant: n writes, decremented by the server, auto-closes at zero
311
+ // 3. path:<p>=POISONED a path nobody may write, ever, for as long as the flag stands
312
+ //
313
+ // `opts.consumes` marks a tool as spending a lease (the write tools). `opts.path` is the region it
314
+ // would write, checked against the path gate.
315
+ function enforceDcoLock(opts = {}) {
316
+ const s = requireSession()
317
+ const state = s.hive.getState()
318
+ const name = s.name
319
+ const status = state[`${name}_status`]
320
+
321
+ // --- gate 3: the path itself may be untouchable, regardless of who you are -------------------
322
+ // Attached to the RESOURCE, not the ACTOR, and that is the new idea here. Every other guard in
323
+ // this system asks "may this agent write?"; none could express "this file must never be written
324
+ // again by anyone." That question has a real answer: a path whose delete has propagated is
325
+ // poisoned — writing it silently resurrects the old content over the new, or leaves two folders
326
+ // permanently disagreeing with no marker and no warning. Tonight that fact lived in a markdown
327
+ // file that agents had to remember to read, and the PM ordered a live credential route deleted
328
+ // anyway. A flag in the state map cannot be forgotten.
329
+ // FIVE VALUES, BECAUSE EACH ONE IMPLIES A DIFFERENT ACTION. A single POISONED flag was the first
330
+ // design and it was wrong — @Claude-Engine caught it live: `path:engine/brain/llm.js = POISONED`
331
+ // was FALSE in its folder, where that path held the correct 8,012-byte file while mine held a
332
+ // 22,312-line wreck. Marking it globally poisoned broadcast ONE FOLDER'S REALITY as room truth,
333
+ // inside the one channel every agent trusts. Its objection is the rule: a per-folder fact must
334
+ // never be stored as a global one.
335
+ //
336
+ // LIVE / OPEN default. Writable under the normal one-spaced-write rule.
337
+ // SUPERSEDED clean, valid code that has been REPLACED. Read freely, do not import, do
338
+ // not write. Distinct from corruption: calling hn.js "poisoned" would send
339
+ // the next agent rewriting a file that is perfectly fine.
340
+ // CORRUPT content unusable in every folder that has measured it. Do not read it as
341
+ // reference; publish the replacement to a NEW path.
342
+ // DIVERGED peers hold DIFFERENT bytes. Nobody writes until it is reconciled, because
343
+ // there is no single truth to write on top of. Use hive_attest to establish
344
+ // who holds what — that is what it is for.
345
+ // DELETED_UNWRITABLE a delete propagated. Writes here vanish or silently resurrect the old
346
+ // content. Never write, never delete, new path only. Globally true.
347
+ if (opts.path) {
348
+ const ps = state[`path:${String(opts.path)}`]
349
+ const stop = {
350
+ POISONED: `[Path Poisoned] ${opts.path} must NOT be written. Publish to a NEW path instead. (Legacy flag — prefer DELETED_UNWRITABLE / CORRUPT / DIVERGED, which say WHY.)`,
351
+ DELETED_UNWRITABLE: `[Path Unwritable] ${opts.path} had a delete propagate. A write here vanishes or silently resurrects the deleted content, and two folders end up permanently disagreeing with no marker. Never write it and never delete it again — publish to a NEW path.`,
352
+ CORRUPT: `[Path Corrupt] ${opts.path} is unusable in every folder that has measured it. Do not write it and do not read it as reference — build the replacement out-of-tree and publish it to a NEW path.`,
353
+ DIVERGED: `[Path Diverged] peers hold DIFFERENT bytes for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
354
+ FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
355
+ }
356
+ if (ps && stop[ps]) throw new Error(stop[ps])
357
+ // An unrecognised value fails CLOSED. A safety registry that silently ignores a flag it does not
358
+ // understand is worse than no registry, because agents will trust it — and a 0.9.0 client
359
+ // ignoring these flags entirely is already the known hole here.
360
+ if (ps && ps !== 'LIVE' && ps !== 'OPEN') {
361
+ throw new Error(`[Path Restricted] path:${opts.path} is set to "${ps}", which this build does not recognise. Refusing the write rather than guessing. Ask the room what it means, or have the owner set it to LIVE.`)
362
+ }
363
+ }
364
+
365
+ if (status === 'LOCKED' || state[`${name}_locked`] === 'true') {
200
366
  throw new Error(`[DCO Enforced] You are locked by the global state machine. Tool execution denied. Block on hive_wait until your status changes.`)
201
367
  }
368
+
369
+ // --- gate 2: leased publication grants -------------------------------------------------------
370
+ // "One clean write per path" was the single most expensive lesson of the JARVIS build: a file
371
+ // written once landed byte-perfect in every folder (7 for 7), and a file written twice in quick
372
+ // succession was destroyed (333 -> 22,312 lines; 104 -> 70,881). Stated as a protocol it was
373
+ // violated within the hour by everyone including its author. A COUNTED lease makes the second
374
+ // write impossible rather than discouraged: the server spends it, and the window shuts itself.
375
+ const lease = /^PUBLISH(?::(\d+))?$/.exec(String(status || ''))
376
+ if (lease && opts.consumes) {
377
+ const left = lease[1] === undefined ? 1 : Number(lease[1])
378
+ if (left <= 0) {
379
+ throw new Error(`[Lease Spent] Your PUBLISH window is used up. Ask for a new one — and if this is a revision of what you just wrote, publish to a NEW path rather than rewriting it.`)
380
+ }
381
+ const next = left - 1
382
+ try {
383
+ s.hive.setState(`${name}_status`, next > 0 ? `PUBLISH:${next}` : 'LOCKED')
384
+ } catch (e) {
385
+ // A schema whose transitions forbid the auto-close must not strand the agent mid-write, and
386
+ // must not silently leave the window open either. Say it loudly and let the write proceed.
387
+ try { s.hive.say(`[!] lease for ${name} could not auto-close (${e.message}) — treat the window as SPENT and do not write again.`) } catch { }
388
+ }
389
+ }
202
390
  }
203
391
 
204
- const server = new Server({ name: 'hivecode', version: '0.8.0' }, { capabilities: { tools: {} } })
392
+ // Bumped from 0.8.0 when message delivery changed (see hive-mailbox.js). Two builds with the
393
+ // same version number but different delivery behaviour is precisely the mixed-build room that
394
+ // breaks rooms — and the standing rule "report your Hivecode version when you join" cannot work
395
+ // if the builds are indistinguishable. So the version is reported on join and by hive_status.
396
+ // 0.10.0 — mail now rides out on EVERY tool result (mailBanner), the DCO gate gained leased
397
+ // PUBLISH:<n> grants and per-PATH poisoning, setState became a real state machine (transitions +
398
+ // requires), and hive_attest/hive_verified were added. Delivery behaviour changed, so by the rule
399
+ // stated directly above this line the version MUST move: a 0.9.0 room and a 0.10.0 room disagree
400
+ // about how a message reaches a working agent, and that is exactly the mixed-build condition that
401
+ // breaks rooms. Two builds that behave differently must never share a number.
402
+ export const VERSION = '0.10.0'
403
+ const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
205
404
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
206
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
405
+ async function dispatch(req) {
207
406
  const { name, arguments: args = {} } = req.params
208
407
  try {
209
408
  // AUTO-RESUME: the MCP host may restart this server process at any time, which
@@ -213,8 +412,54 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
213
412
  if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
214
413
  switch (name) {
215
414
  case 'hive_join': return text(await joinRoom(args))
216
- case 'hive_say': { requireSession().hive.say(String(args.text || '')); return text(`sent: ${args.text}`) }
217
- case 'hive_read_chat': { const n = args.limit || 30; const msgs = chatArray().slice(-n).map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`); return text(msgs.join('\n') || '(no messages yet)') }
415
+ case 'hive_say': {
416
+ const raw = String(args.text || '')
417
+ // The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
418
+ // a human reading the log, not just to the recipient's tool output.
419
+ const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
420
+ requireSession().hive.say(body)
421
+ return text(`sent: ${body}`)
422
+ }
423
+ case 'hive_read_chat': {
424
+ const me = requireSession().name
425
+ const all = chatArray()
426
+ if (args.mentioningMe) {
427
+ // Backlog recovery: the whole history, filtered to what was addressed to you. This
428
+ // is what the PM agent had to do by hand with grep when delivery failed.
429
+ const mine = all.filter((m) => mentionsName(m.text, me))
430
+ if (!mine.length) return text('(nothing in this room has @mentioned you)')
431
+ return text(`${mine.length} message(s) addressed to you:\n` + mine.map(formatMessage).join('\n'))
432
+ }
433
+ if (args.unreadOnly) {
434
+ const seen = mailbox().peek(all)
435
+ if (!seen.unreadCount) return text('(nothing unread)')
436
+ return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
437
+ }
438
+ const n = args.limit || 30
439
+ const msgs = all.slice(-n).map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`)
440
+ return text(msgs.join('\n') || '(no messages yet)')
441
+ }
442
+ case 'hive_check': {
443
+ const s = requireSession()
444
+ const all = chatArray()
445
+ const box = mailbox()
446
+ const got = args.peek ? box.peek(all) : box.take(all)
447
+ if (!args.peek) persistCursor()
448
+ const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
449
+ if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
450
+ const parts = []
451
+ if (got.priority === 'stop') {
452
+ parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
453
+ parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
454
+ } else if (got.mentioned) {
455
+ parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
456
+ parts.push(got.mentions.map(formatMessage).join('\n'))
457
+ }
458
+ const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
459
+ if (ambient.length) parts.push(`room activity (${ambient.length}):\n` + ambient.map(formatMessage).join('\n'))
460
+ if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
461
+ return text(parts.join('\n\n'))
462
+ }
218
463
  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)') }
219
464
  case 'hive_assign': { enforceDcoLock(); const id = requireSession().hive.assign(String(args.to || ''), String(args.text || '')); return id ? text(`assigned task ${id} to ${args.to} (pending approval if they have an owner)`) : err('need to and text') }
220
465
  case 'hive_read_tasks': {
@@ -225,15 +470,26 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
225
470
  }
226
471
  case 'hive_wait': {
227
472
  const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
228
- const r = await waitForWork(ms)
473
+ const r = await waitForWork(ms, { wakeOn: args.wakeOn })
229
474
  const globalState = requireSession().hive.getState()
230
475
  const stateStr = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
231
476
 
477
+ const me = requireSession().name
478
+ // Direct address is separated from ambient traffic on every path. It used to be that
479
+ // only a PAUSED agent got this distinction, so a working agent could not tell "stop,
480
+ // wrong file" from two other agents talking to each other.
481
+ const mentionBlock = (r) => (r.mentions && r.mentions.length)
482
+ ? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
483
+ : ''
484
+ const ambientBlock = (r) => {
485
+ const rest = (r.messages || []).filter((m) => !mentionsName(m.text, me))
486
+ return rest.length ? `room activity:\n${rest.map(formatMessage).join('\n')}` : ''
487
+ }
488
+
232
489
  if (r && r.paused) {
233
- if (r.mentioned) {
234
- const parts = []
235
- if (r.newChat && r.newChat.length) parts.push(`💬 Mentioned while paused:\n${r.newChat.map((m) => `${m.by}(${m.kind}): ${m.text}`).join('\n')}`)
236
- if (r.tasks && r.tasks.length) parts.push(`📌 Directed task(s):\n${r.tasks.map(fmtTask).join('\n')}`)
490
+ const parts = [mentionBlock(r), ambientBlock(r)].filter(Boolean)
491
+ if (r.tasks && r.tasks.length) parts.push(`📌 Directed task(s):\n${r.tasks.map(fmtTask).join('\n')}`)
492
+ if (parts.length) {
237
493
  return text(`⏸ You are PAUSED, but were directly addressed. Handle ONLY this, then call hive_wait again (you stay paused until a human resumes you — don't pick up other queued work):\n\n${parts.join('\n\n')}`)
238
494
  }
239
495
  return text(`⏸ You are PAUSED by mission control. Stop new work and wait. You will be woken if someone @mentions you or assigns you a task; otherwise just call hive_wait again.${stateStr}`)
@@ -241,19 +497,40 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
241
497
  if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${stateStr}`)
242
498
  const parts = []
243
499
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
500
+ const mb = mentionBlock(r); if (mb) parts.push(mb)
244
501
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
245
- if (r.newChat.length) parts.push(`NEW MESSAGES:\n${r.newChat.map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`).join('\n')}`)
502
+ const ab = ambientBlock(r); if (ab) parts.push(ab)
246
503
  return text(`${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
247
504
  }
248
505
  case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
249
506
  case 'hive_read_state': { return text(requireSession().hive.getState()) }
250
507
  case 'hive_set_schema': { requireSession().hive.setSchema(String(args.key), args.rules); return text(`Set schema validation for [${args.key}]`) }
508
+ case 'hive_attest': {
509
+ const f = String(args.file || ''); if (!f) return err('need a file path')
510
+ if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
511
+ const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
512
+ const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines`).join('\n ')
513
+ if (r.diverged) {
514
+ return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.`)
515
+ }
516
+ return text(`attested ${f} at ${args.bytes} bytes.\n ${who}\n${r.count >= 2 ? 'QUORUM: 2+ peers agree — this publish is confirmed.' : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'}`)
517
+ }
518
+ case 'hive_verified': {
519
+ const f = String(args.file || ''); if (!f) return err('need a file path')
520
+ const r = requireSession().hive.verified(f)
521
+ if (!r.count) return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.`)
522
+ const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
523
+ return text(`${f}\n ${who}\n\n${r.diverged ? '[!] DIVERGED — peers hold different bytes. Do not write or build on this path.' : r.quorum ? 'QUORUM: confirmed by 2+ peers at the same size.' : 'Attested by ONE peer only — not yet proof.'}`)
524
+ }
251
525
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
252
526
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
253
527
  case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
254
528
  case 'hive_claim': {
255
- enforceDcoLock();
256
529
  const region = String(args.region || ''); if (!region) return err('need a region (file path)')
530
+ // Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
531
+ // always a mistake, and refusing here is far cheaper than refusing after the agent has
532
+ // built the content it intended to write.
533
+ enforceDcoLock({ path: region });
257
534
  const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
258
535
  if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
259
536
  const c = requireSession().hive.senseClaim(region)
@@ -261,15 +538,148 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
261
538
  }
262
539
  case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
263
540
  case 'hive_claims': { const b = requireSession().hive.claimsBoard(); return text(b.length ? b.map((c) => `${c.region} — ${c.by}${c.intent ? ` (${c.intent})` : ''}`).join('\n') : '(nothing claimed right now — all open)') }
541
+ case 'hive_edit': {
542
+ const editPath = String(args.file || '')
543
+ // Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
544
+ // indistinguishable from no lease at all.
545
+ enforceDcoLock({ consumes: true, path: editPath })
546
+ const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
547
+ if (r.error) return err(r.error)
548
+ return text(r.conflicted
549
+ ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
550
+ : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
551
+ }
552
+ case 'hive_fork': {
553
+ const file = String(args.file || ''); if (!file) return err('need a file path')
554
+ const f = requireSession().hive.forkInfo(file)
555
+ if (!f) return text(`(no active fork on ${file} — nothing to resolve)`)
556
+ const vs = f.versions.map((v, i) => `--- VERSION ${i + 1}: ${v.name} — signed intent: "${v.intent || '(none stated)'}" ---\n${v.text ?? '(text unavailable)'}`).join('\n\n')
557
+ return text(`ACTIVE FORK on ${file} — ${f.versions.length} agents changed the same region concurrently. Reconcile ALL intents into one version and submit it with hive_resolve.\n\n--- COMMON BASE (what everyone edited from) ---\n${f.base ?? '(unavailable)'}\n\n${vs}`)
558
+ }
559
+ case 'hive_resolve': {
560
+ const file = String(args.file || ''); if (!file) return err('need a file path')
561
+ // A resolution IS a write, so it spends a lease and respects the path gate. Resolving a
562
+ // poisoned path is the exact move that produced two permanently disagreeing folders.
563
+ enforceDcoLock({ consumes: true, path: file })
564
+ const r = requireSession().hive.resolveFork(file, String(args.code ?? ''))
565
+ if (!r.ok) return err(`resolution NOT landed: ${r.reason}`)
566
+ const dropped = (r.coverage || []).filter((c) => !c.covered)
567
+ return text(`✓ resolution LANDED in ${file} — validated by ICR (parses, no dangling refs), signed by you, synced to every peer.${dropped.length ? ` ⚠ Intent coverage: ${dropped.map((c) => `${c.name}'s changes (${c.missing.slice(0, 5).join(', ')}) did not survive`).join('; ')} — if that was not deliberate, submit a better reconciliation.` : ' Full intent coverage: every side\'s changes survived.'}`)
568
+ }
264
569
  case 'hive_members': { const now = Date.now(); return text(requireSession().hive.members().map((m) => `${m.name} (${m.kind})${m.editing && now - m.editing.at < 15000 ? ` — editing ${m.editing.file}` : ''}`).join('\n') || '(none)') }
265
- case 'hive_status': { const s = requireSession(); return text({ room: s.room, relay: s.relay, dir: s.dir, name: s.name }) }
570
+ case 'hive_status': {
571
+ const s = requireSession()
572
+ return text({ room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, unread: mailbox().peek(chatArray()).unreadCount })
573
+ }
266
574
  case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
267
575
  default: return err(`unknown tool: ${name}`)
268
576
  }
269
577
  } catch (e) { return err(`error: ${e.message}`) }
578
+ }
579
+
580
+ // MAIL PIGGYBACK — the delivery channel that cannot be ignored.
581
+ //
582
+ // Everything else that tries to reach a busy agent is best-effort. `notifications/hive_mail` and
583
+ // `notifications/hive_state_changed` are CUSTOM MCP methods, and the spec requires a receiver to
584
+ // IGNORE notification methods it does not recognise — so on most hosts they are a doorbell wired to
585
+ // nothing. That is why a live four-agent build accumulated 345 mentions in the room log while agents
586
+ // repeatedly reported never seeing the one addressed to them, and why the room resorted to disk files
587
+ // and a shell watcher to do the job of a coordination tool.
588
+ //
589
+ // A TOOL RESULT, by contrast, lands in the model's context by definition. So mail rides out on every
590
+ // single tool call. An agent that is heads-down still calls tools constantly — that is what being
591
+ // heads-down IS — and now every one of those calls tells it who is waiting.
592
+ //
593
+ // PEEK, NOT TAKE, and the distinction is the whole design: this is a DOORBELL, not a delivery. It
594
+ // consumes nothing and advances no cursor, so hive_wait/hive_check remain the only things that hand
595
+ // over a message and mark it read. Delivering here instead would make the full text arrive on a
596
+ // random unrelated tool call and vanish from the inbox, which is worse than not delivering at all.
597
+ // A CONTEXT IS APPEND-ONLY, AND THAT CHANGES THE DESIGN.
598
+ //
599
+ // The obvious implementation — append the banner whenever mail is pending — is wrong in two ways, and
600
+ // both are the very drift SMP exists to prevent:
601
+ //
602
+ // 1. SPAM. Mail stays pending until the agent reads it, so 50 tool calls produce 50 identical
603
+ // banners, burning context to say one thing.
604
+ // 2. STALE TRUTH. Nothing can REMOVE text from a transcript. Once a banner is in context it is there
605
+ // for the rest of the session, so after the agent reads its mail the old "URGENT MAIL" notices
606
+ // remain, contradicting the present. An agent scanning back acts on a message handled ten
607
+ // minutes ago.
608
+ //
609
+ // Since removal is impossible, the only correct approach is to make LATER TEXT AUTHORITATIVE and say
610
+ // so explicitly:
611
+ //
612
+ // - emit ONLY when the mail state actually changes, so one new message = one notice
613
+ // - NUMBER each notice, and state that it supersedes every earlier one
614
+ // - emit exactly ONE "cleared" notice when the inbox empties, which retires the stale banners
615
+ // instead of leaving them as the newest thing the agent can see
616
+ //
617
+ // Signature includes the session name so a rejoin re-announces rather than inheriting a stale state.
618
+ let mailSig = ''
619
+ let mailNotice = 0
620
+
621
+ function mailBanner() {
622
+ try {
623
+ if (!session || !session.mailbox) return ''
624
+ const m = session.mailbox.peek(chatArray())
625
+ const frame = (body) => `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE above) ---\n${body}\n--- end notice #${mailNotice} ---`
626
+
627
+ if (!m.mentioned) {
628
+ // Emit the all-clear once, and only if we had previously announced something. Without this the
629
+ // last thing in context stays "you have 2 unread", forever, after they were read.
630
+ if (!mailSig) return ''
631
+ mailSig = ''
632
+ mailNotice += 1
633
+ return frame('MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.')
634
+ }
635
+
636
+ const last = m.mentions[m.mentions.length - 1] || {}
637
+ // Keyed on the NEWEST mention, not the count: a count alone would stay silent when one message is
638
+ // read and another arrives, and would re-fire on unrelated ambient chatter.
639
+ const sig = `${session.name}|${m.mentions.length}|${last.at || ''}|${last.by || ''}`
640
+ if (sig === mailSig) return '' // already announced — the notice is in context, do not repeat it
641
+ mailSig = sig
642
+ mailNotice += 1
643
+
644
+ const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
645
+ const urgent = m.priority === 'stop'
646
+ return frame(`${urgent ? '[!] URGENT MAIL' : 'UNREAD MAIL'}: ${m.mentions.length} message(s) addressed to you from ${who}. `
647
+ + `${urgent ? 'Handle before continuing. ' : ''}Call hive_wait or hive_check to read them — this notice consumed nothing.`)
648
+ } catch { return '' }
649
+ }
650
+
651
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
652
+ const res = await dispatch(req)
653
+ // Never let the banner break a tool: append only to a well-formed text result, and skip the two
654
+ // tools that already deliver mail themselves (appending would double-report the same messages).
655
+ const skip = req.params && (req.params.name === 'hive_wait' || req.params.name === 'hive_check')
656
+ if (skip) return res
657
+ const banner = mailBanner()
658
+ if (!banner) return res
659
+ try {
660
+ if (res && Array.isArray(res.content) && res.content[0] && typeof res.content[0].text === 'string') {
661
+ return { ...res, content: [{ ...res.content[0], text: res.content[0].text + banner }, ...res.content.slice(1)] }
662
+ }
663
+ } catch { /* fall through — a delivered result matters more than a banner */ }
664
+ return res
270
665
  })
271
666
 
272
- const transport = new StdioServerTransport()
273
- await server.connect(transport)
274
- // stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
275
- console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
667
+ // Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
668
+ // hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
669
+ const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
670
+ if (isEntryPoint) {
671
+ const transport = new StdioServerTransport()
672
+ await server.connect(transport)
673
+ // stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
674
+ console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
675
+ }
676
+
677
+ /** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
678
+ export const _test = {
679
+ waitForWork,
680
+ mailbox,
681
+ setSession(s) { session = s },
682
+ getSession() { return session },
683
+ callTool: (name, args = {}) =>
684
+ server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
685
+ }