hivecode-mcp 0.11.5 → 0.11.6

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
@@ -34,12 +34,30 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
34
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
35
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
36
  import { startSync, parseLink } from './sync.js'
37
- import { createMailbox, formatMessage, mentionsName, mentionedNames, priorityOf, ambientDigest } from './hive-mailbox.js'
37
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
38
+ import { INSTRUCTIONS } from './hive-instructions.js'
39
+ import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
40
+ import * as OB from './hive-obligations.js'
41
+ import * as RC from './hive-receipts.js'
42
+ import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
38
43
 
39
44
  const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
40
45
  let session = null // { hive, room, relay, dir, name, mailbox }
41
46
 
42
- const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] })
47
+ // ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
48
+ // session: a rejoin does not empty the agent's context, so resetting the throttle there would
49
+ // re-send everything the agent can still see -- the exact waste this removes.
50
+ const throttle = makeThrottle()
51
+
52
+ const text = (s) => {
53
+ const body = typeof s === 'string' ? s : JSON.stringify(s, null, 2)
54
+ // THE METER. Every tool result leaves through here, so this count is exact rather than
55
+ // sampled -- it is the local equivalent of the PTY output bytes Agent Relay's broker measures,
56
+ // and it is the only evidence available that an agent has done enough since it last saw a
57
+ // block for that block to have been compacted away.
58
+ try { throttle.note(body.length) } catch { /* the meter must never break a tool result */ }
59
+ return { content: [{ type: 'text', text: body }] }
60
+ }
43
61
  const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
44
62
 
45
63
  function resolveRoom(link, dir) {
@@ -69,6 +87,12 @@ function readSessionFile() {
69
87
  function persistCursor() {
70
88
  if (!session || !session.mailbox) return
71
89
  persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
90
+ // AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
91
+ // what makes "did they actually read it" answerable by anybody other than me. The cursor only
92
+ // advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
93
+ // past a message is the one honest piece of evidence this system has that the message reached
94
+ // somebody's context. Before this, that evidence existed and was thrown away on disk.
95
+ try { RC.publishCursor(session.hive.doc.getMap('cursors'), session.name, session.mailbox.cursor) } catch { /* a room that predates receipts */ }
72
96
  }
73
97
  async function tryResumeSession() {
74
98
  try {
@@ -79,6 +103,17 @@ async function tryResumeSession() {
79
103
  }
80
104
 
81
105
  async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
106
+ // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
107
+ // exists, so validating after that point would leave an agent with a bad name holding nothing at
108
+ // all -- it would lose a working room to a refusal it could have been given for free.
109
+ const nameCheck = validateMemberName(name)
110
+ if (!nameCheck.ok) {
111
+ return `${nameCheck.error}
112
+
113
+ Nothing was joined and your current session is untouched. Re-run hive_join with name: "${nameCheck.suggested}".
114
+
115
+ 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.`
116
+ }
82
117
  if (session) { try { session.hive.stop() } catch {} ; session = null }
83
118
  const ROOT = path.resolve(dir)
84
119
  fs.mkdirSync(ROOT, { recursive: true })
@@ -110,13 +145,52 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
110
145
  }
111
146
  session = { hive, room, relay, dir, name, owner, mailbox: null }
112
147
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
113
- // wait for the first sync so members/board/rules are ready
148
+ // Wait for the first sync so members/board/rules are ready.
149
+ //
150
+ // REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
151
+ // caller returned a cheerful "joined" banner whether or not the relay had accepted us. On a
152
+ // relay at capacity that is a false success report: the agent is told it is in a room, reads
153
+ // the rules, claims files and writes code, and NONE of it reaches anybody. It happened for
154
+ // real -- a relay answered 38,011 connections with Q430 while three agents believed they were
155
+ // collaborating. An agent cannot detect this on its own; the only honest place to catch it is
156
+ // here, at the moment we know whether the socket came up.
157
+ let synced = false
158
+ let connErr = ''
114
159
  await new Promise((resolve) => {
115
160
  let done = false
116
161
  const finish = () => { if (!done) { done = true; resolve() } }
117
- hive.provider.on('sync', (s) => s && finish())
162
+ hive.provider.on('sync', (s) => { if (s) { synced = true; finish() } })
163
+ // A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
164
+ // Q-code and destroys the socket, so this event is the only place the reason is visible.
165
+ hive.provider.on('connection-error', (ev) => {
166
+ const code = ev && (ev.code || ev.status || ev.type)
167
+ if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
168
+ })
118
169
  setTimeout(finish, 8000) // don't hang if the relay is cold
119
170
  })
171
+ if (!synced) {
172
+ const why = connErr || 'no sync within 8s -- the relay did not answer'
173
+ return [
174
+ `NOT JOINED -- ${why}.`,
175
+ `room: ${room}`,
176
+ `relay: ${relay}`,
177
+ `folder: ${ROOT}`,
178
+ '',
179
+ 'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
180
+ 'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
181
+ 'you do now is invisible to the team and can be silently overwritten later.',
182
+ '',
183
+ 'Most likely causes, in the order worth checking:',
184
+ ' - the relay is at capacity (Q430). Hivecode opens one socket PER FILE, so a large',
185
+ ' folder can exhaust it by itself. Check <relay-http>/health: connections vs',
186
+ ' limits.maxConnsTotal. Raise HIVE_MAX_CONNS_TOTAL, or narrow what syncs via .gitignore.',
187
+ ' - the relay is not running, or the port/room in the link is wrong.',
188
+ ' - the room is secured and this link carries no token (Q4xx on connect).',
189
+ '',
190
+ 'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
191
+ 'it reports `connected` and `filesSynced`, and both are authoritative.',
192
+ ].join('\n')
193
+ }
120
194
  // The mailbox is created AFTER the first sync, so chat.length is the real room history and
121
195
  // not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
122
196
  // A resumed session carries its saved cursor forward instead, so anything posted while this
@@ -130,8 +204,18 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
130
204
 
131
205
  // Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
132
206
  hive.doc.getMap('swarm_state').observe(() => {
207
+ // SAME DEFECT AS THE MAIL DOORBELL BELOW, AND FOUND THE SAME WAY -- a test crashed inside a
208
+ // Yjs observer with the stack pointing at a `try` that looked like it had the case covered.
209
+ // `notification()` is async: a failure is a REJECTED PROMISE, which sails past this catch,
210
+ // becomes an unhandled rejection and takes the whole agent down.
211
+ //
212
+ // Worth recording that the first fix only repaired the copy in the chat observer. This one
213
+ // survived untouched for another two tickets and then crashed a second test -- which is the
214
+ // duplicated-wire-contract lesson arriving in person: repair the CLASS, not the instance.
215
+ // Both call sites are now the same shape, deliberately.
133
216
  try {
134
- server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } })
217
+ Promise.resolve(server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } }))
218
+ .catch(() => { /* no transport, or a client that does not take notifications */ })
135
219
  } catch (e) {
136
220
  // client might not support notifications, swallow error
137
221
  }
@@ -164,7 +248,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
164
248
  // 0, and a later single mention must still ring. `>` would go permanently silent.
165
249
  if (!m.mentioned || m.mentions.length === lastMailSignal) return
166
250
  lastMailSignal = m.mentions.length
167
- server.notification({
251
+ // THE CATCH BELOW DOES NOT COVER THIS CALL. `notification()` is async, so a failure inside
252
+ // it is a REJECTED PROMISE, not a thrown exception -- it sails past the surrounding
253
+ // try/catch, becomes an unhandled rejection, and takes the whole agent down. Observed
254
+ // 19 Sep 2026: a second agent posting chat to a server whose transport was not connected
255
+ // crashed the process from inside this Yjs observer, with the stack pointing at a `try`
256
+ // that looked like it had the case covered.
257
+ //
258
+ // A doorbell that can kill the agent is strictly worse than no doorbell -- and this one is
259
+ // already optional by design (custom MCP notifications are ignorable by spec), so the right
260
+ // behaviour on any failure is silence.
261
+ Promise.resolve(server.notification({
168
262
  method: 'notifications/hive_mail',
169
263
  params: {
170
264
  mentioned: true,
@@ -174,7 +268,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
174
268
  latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
175
269
  hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
176
270
  },
177
- })
271
+ })).catch(() => { /* no transport, or a client that does not take notifications */ })
178
272
  } catch (e) {
179
273
  // client might not support notifications, swallow error
180
274
  }
@@ -196,10 +290,21 @@ function requireSession() { if (!session) throw new Error('not in a room — cal
196
290
  /** The read cursor for this participant. Lazily built if a session predates it. */
197
291
  function mailbox() {
198
292
  const s = requireSession()
293
+ // hive_join has just handed over the full HIVE_RULES.md, so the nudge would be pure noise on
294
+ // the next wait. Marking it sent here is what makes the join and the reminder one mechanism
295
+ // rather than two that disagree.
296
+ try { throttle.sent('rules', RULES_NUDGE) } catch { }
199
297
  if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
200
298
  return s.mailbox
201
299
  }
202
300
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
301
+ // Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
302
+ // keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
303
+ function findBusy(map, name) {
304
+ const want = String(name || '').toLowerCase()
305
+ for (const [k, v] of map.entries()) if (String(k).toLowerCase() === want) return v
306
+ return null
307
+ }
203
308
  function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
204
309
  function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
205
310
  const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.decidedBy ? ` (by ${t.decidedBy})` : ''}`
@@ -207,6 +312,32 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
207
312
  // Block until there is approved work for me (or a new chat message), then return.
208
313
  // This is how the agent reacts the INSTANT the owner approves — no polling loop,
209
314
  // no interval to tune. Resolves immediately if approved work already exists.
315
+ /** WAKE ON STATE — the predicate that lets a roster run itself.
316
+ *
317
+ * Without this, hive_wait only returns on work assigned TO you or a message naming you, so every
318
+ * multi-agent roster silently depends on agents remembering to @ each other after each step. The
319
+ * rules ASK them to (sync.js: "block on hive_wait until your specific state flag turns
320
+ * APPROVED/READY") and nothing MAKES them, so a night roster stalls with no error: the reviewer
321
+ * is asleep, the tests went green an hour ago, and nobody said so.
322
+ *
323
+ * Shapes, all on one state key:
324
+ * { key: 'tests', is: 'GREEN' } exactly this value
325
+ * { key: 'tests', in: ['GREEN', 'SKIP'] } any of these
326
+ * { key: 'tests', not: 'RED' } anything except this (INCLUDING unset)
327
+ * { key: 'tests' } any value at all, i.e. the key exists
328
+ *
329
+ * `not` deliberately matches an unset key. A reviewer told "wake unless the build is RED" should
330
+ * wake on a fresh room where nobody has set `build` yet -- treating absent as RED would hang it
331
+ * forever waiting for a flag no one is going to write. */
332
+ export function stateMatches(until, get) {
333
+ if (!until || !until.key) return false
334
+ const v = get(until.key)
335
+ if (Object.prototype.hasOwnProperty.call(until, 'is')) return String(v) === String(until.is)
336
+ if (Array.isArray(until.in)) return until.in.some((x) => String(x) === String(v))
337
+ if (Object.prototype.hasOwnProperty.call(until, 'not')) return String(v) !== String(until.not)
338
+ return v !== undefined && v !== null
339
+ }
340
+
210
341
  function waitForWork(timeoutMs, opts = {}) {
211
342
  const s = requireSession()
212
343
  const tmap = s.hive.doc.getMap('tasks')
@@ -251,6 +382,13 @@ function waitForWork(timeoutMs, opts = {}) {
251
382
  }
252
383
  // Queued work no longer hides the mail. This returned `newChat: []` outright, which made
253
384
  // the busiest agent in the room the one guaranteed to see no messages at all.
385
+ // A state the caller is waiting for, checked BEFORE anything blocks: if it is already true the
386
+ // agent must not sit through a timeout waiting to be told something that happened an hour ago.
387
+ const state = s.hive.doc.getMap('swarm_state')
388
+ const until = opts.until && opts.until.key ? opts.until : null
389
+ const stateHit = () => stateMatches(until, (k) => state.get(k))
390
+ if (until && stateHit()) return Promise.resolve(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } }))
391
+
254
392
  const ready = accepted()
255
393
  if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
256
394
  // Anything already unread is handed over immediately rather than waited past — that backlog
@@ -261,7 +399,7 @@ function waitForWork(timeoutMs, opts = {}) {
261
399
  }
262
400
  return new Promise((resolve) => {
263
401
  let done = false
264
- const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
402
+ const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); if (until) { try { state.unobserve(onState) } catch { /* never observed */ } } resolve(v) } }
265
403
  const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
266
404
  const onChat = () => {
267
405
  const seen = box.peek(chat.toArray())
@@ -270,7 +408,11 @@ function waitForWork(timeoutMs, opts = {}) {
270
408
  finish(deliver({ tasks: accepted() }))
271
409
  }
272
410
  const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
273
- tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl)
411
+ // A paused agent is NOT woken by state: pausing means stop picking up work, and a state flag
412
+ // turning green is the room offering it work. It re-checks the predicate on its next wait
413
+ // after a human resumes it.
414
+ const onState = () => { if (until && !paused() && stateHit()) finish(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } })) }
415
+ tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
274
416
  // On timeout, flush anything that arrived rather than dropping it: a message must never
275
417
  // expire unread just because the wait window closed on it.
276
418
  setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
@@ -284,8 +426,8 @@ const TOOLS = [
284
426
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
285
427
  { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
286
428
  inputSchema: { type: 'object', properties: {} } },
287
- { 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.',
288
- 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." } } } },
429
+ { name: 'hive_wait', description: 'BLOCK until there is approved work for you, unread mail arrives, or a project-state flag you named reaches the value you are waiting for — then return all of it. Use this as your main loop: call hive_wait; do the work; hive_complete; call hive_wait again. Returns instantly if any of those is ALREADY true, so nothing that happened while you were busy can be missed. Pass `until` to wait on the state machine instead of on somebody remembering to message you — that is how a roster runs itself overnight: the tester sets tests=GREEN, and the reviewer waiting on it wakes by itself.',
430
+ inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: "'any' (default) wakes on any new message; 'mention' only wakes when you are @mentioned or assigned a task — use it in a noisy room. Ambient messages are still delivered on the next wake either way, never dropped." }, until: { type: 'object', description: 'wake when a GLOBAL PROJECT STATE key reaches a value. { key, is } for one exact value, { key, in: [...] } for any of several, { key, not } for anything else (an unset key counts as "not"), or { key } alone for "as soon as this is set at all". The result carries stateReached with the value that fired.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
289
431
  { 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.',
290
432
  inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
291
433
  { name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
@@ -294,16 +436,28 @@ const TOOLS = [
294
436
  inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
295
437
  { name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
296
438
  inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
297
- { 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.',
298
- 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'] } },
439
+ { name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login"). Address someone directly with @Name — that is what makes it land as mail rather than ambient chatter. Set urgent:true when you need them to stop what they are doing. Set needsReply:true when you are ASKING something — the room then tracks that you are owed an answer, and tells you if you addressed somebody who is not here.',
440
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = prefix [!] so the recipient treats it as "handle before continuing", not "read at your next boundary"' }, needsReply: { type: 'boolean', description: 'true = this is a QUESTION and you are owed an answer. Records an obligation against everyone you named. It stays open until YOU call hive_answered -- the person you asked cannot close it, and answering is not closing. If you named someone who is not in the room you are told so immediately, instead of waiting forever.' }, replyWithinMinutes: { type: 'number', description: 'how long before you are reminded (default 10). The reminder arrives on a hive_wait you were going to make anyway, so it costs nothing.' } }, required: ['text'] } },
441
+ { name: 'hive_delivered', description: 'Did anyone actually READ what you said? "sent" only means the message entered your local copy. This reports, per recipient: READ (their read position passed it — real evidence), UNREAD (they are here and behind), UNKNOWN (they publish no read position, e.g. a bridge or a human client — so their silence means NOTHING either way), UNREACHABLE (they left, or you are offline). Read it before concluding an agent is ignoring you, and before believing a handoff landed.',
442
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
443
+ { name: 'hive_answered', description: 'Close a question YOU asked with needsReply — you are saying you got what you needed. Only the asker can call this: the agent you asked cannot mark its own answer sufficient, and a reply alone does not close anything. Pass id for one, or from:"Name" to close everything you asked that agent.',
444
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id from hive_say or hive_owed' }, from: { type: 'string', description: 'close every open question you asked this agent' } } } },
445
+ { name: 'hive_owed', description: 'Who owes an answer to whom, and for how long. Read this when the room has gone quiet: an agent that stopped replying looks exactly like an agent that is thinking, and this is the difference. Shows questions aimed at YOU (answer these) and questions you asked (close these with hive_answered). Anything marked STALE has been reminded three times and is now the room\'s problem, not one agent\'s.',
446
+ inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
447
+ { name: 'hive_busy', description: 'Tell the room you are mid-task, so anyone who @mentions you is told to expect a delay AT THE MOMENT THEY SEND, instead of waiting on a reply that is not coming yet. This posts no message and silences nothing — you still receive mail and must still call hive_check between edits. It expires by itself, so a crash cannot leave you permanently "busy". Clear it when you finish.',
448
+ inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are working on, in a few words — the sender reads this' }, minutes: { type: 'number', description: 'how long to hold it (default 10). It expires on its own either way.' }, clear: { type: 'boolean', description: 'true = I am free again' } } } },
299
449
  { 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.',
300
450
  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)' } } } },
301
451
  { 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.',
302
452
  inputSchema: { type: 'object', properties: {} } },
303
453
  { 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).',
304
454
  inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) you want to edit' }, intent: { type: 'string', description: 'short note on what you intend to do' } }, required: ['region'] } },
305
- { name: 'hive_release', description: 'Release a file/region you claimed, so other agents can take it. Call this when you finish editing it.',
306
- inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) to release' } }, required: ['region'] } },
455
+ { name: 'hive_release', description: 'Hand a file back so other agents can take it. If the room has set a gate, this RUNS it in your folder first and a failing check REFUSES the handoff — you keep the file, nobody else can take it, and retrying will not help. Fix what the gate reported, or pass force with a reason if you are abandoning the work (recorded against your name and announced).',
456
+ inputSchema: { type: 'object', properties: {
457
+ region: { type: 'string', description: 'the file path (or region id) to release' },
458
+ force: { type: 'boolean', description: 'release even though the gate failed. Recorded against your name in the room and posted to chat — use it for abandoned work, never to get past a failing check.' },
459
+ why: { type: 'string', description: 'why the work is being abandoned. Give this whenever force is true; a forced release with no reason is recorded as exactly that.' },
460
+ }, required: ['region'] } },
307
461
  { 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.',
308
462
  inputSchema: { type: 'object', properties: {} } },
309
463
  { 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.',
@@ -439,7 +593,15 @@ export const VERSION = (() => {
439
593
  } catch { /* fall through */ }
440
594
  return '0.0.0-unknown'
441
595
  })()
442
- const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
596
+ // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
597
+ // makes it the only channel that reaches an agent before its first tool call. Everything else we
598
+ // have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
599
+ // away, and the rule that matters most (hive_wait is your idle state; ending your turn makes you
600
+ // unreachable) is exactly the one an agent needs up front. See hive-instructions.js.
601
+ const server = new Server(
602
+ { name: 'hivecode', version: VERSION },
603
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS },
604
+ )
443
605
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
444
606
  async function dispatch(req) {
445
607
  const { name, arguments: args = {} } = req.params
@@ -452,6 +614,14 @@ async function dispatch(req) {
452
614
  switch (name) {
453
615
  case 'hive_join': return text(await joinRoom(args))
454
616
  case 'hive_say': {
617
+ // THE CONNECTION IS CHECKED BEFORE ANYTHING ELSE. Until today this tool reported
618
+ // "sent to @Builder: ..." with the socket down, which is a false success in the most
619
+ // literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
620
+ // word the agent reads is the same word it reads on success. status().connected was
621
+ // right there and free the whole time.
622
+ const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
623
+ const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
624
+ const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
455
625
  const raw = String(args.text || '')
456
626
  // The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
457
627
  // a human reading the log, not just to the recipient's tool output.
@@ -463,12 +633,85 @@ async function dispatch(req) {
463
633
  // Immediate, per-call feedback, at the moment the mistake is made.
464
634
  const named = mentionedNames(body)
465
635
  if (!named.length) {
466
- return text(`sent: ${body}\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
636
+ return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
467
637
  `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
468
638
  `agents you actually need with @Name (hive_members lists who is here). Use an ` +
469
639
  `unaddressed message only for a genuine announcement to the whole room.`)
470
640
  }
471
- return text(`sent to ${named.map((n) => '@' + n).join(', ')}: ${body}`)
641
+ // WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
642
+ // "they left" or "they are mid-task", and it is worth strictly more here than in a
643
+ // reply: it arrives before the sender starts waiting, and it costs the room no message.
644
+ // This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
645
+ const s = requireSession()
646
+ const present = new Set(s.hive.members().map((m) => String(m.name).toLowerCase()))
647
+ const busyMap = s.hive.doc.getMap('busy')
648
+ const notes = named
649
+ .filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
650
+ .map((n) => describeRecipient(n, findBusy(busyMap, n), present.has(n.toLowerCase())))
651
+ .filter(Boolean)
652
+ const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
653
+ // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named,
654
+ // and `absent` is stamped NOW -- because "was anyone even there when I asked" is a fact
655
+ // about the past, and recomputing it later would let a question asked into an empty room
656
+ // start looking fine the moment somebody walks in. This is the 19 Sep 2026 failure
657
+ // exactly: one message to @Reviewer, who had never been in the room, then six hours of
658
+ // silence that no tool, log or process reported.
659
+ let obStr = ''
660
+ if (args.needsReply) {
661
+ const obMap = s.hive.doc.getMap('obligations')
662
+ // Floor in MILLISECONDS, not minutes. `Math.max(1, mins)` silently turned a caller's
663
+ // 0.05 into 1 -- a clamp that changes what was asked for and says nothing, which is the
664
+ // same shape as every other defect on this project. One second is enough to stop a
665
+ // zero-or-negative busy loop, and everything above it is honoured as written.
666
+ const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
667
+ const targets = mentionsAll(body)
668
+ ? s.hive.members().map((m) => String(m.name)).filter((n) => n.toLowerCase() !== s.name.toLowerCase())
669
+ : named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
670
+ // PRESENCE AND PROVENANCE ARE DIFFERENT FACTS AND THEY DECIDE DIFFERENT THINGS.
671
+ // `absent` decides the WARNING: nobody by that name is connected right now, so say so
672
+ // at once. `neverSeen` decides the LIFETIME: nobody by that name has ever joined or
673
+ // spoken here, so this is noise rather than a teammate who stepped out.
674
+ // Conflating them would expire a real debt: on 20 Sep an obligation against
675
+ // @DeepMind-Agent was stamped absent because it had dropped between turns, and it came
676
+ // back ninety minutes later and answered. A ten-minute timer would have deleted it, and
677
+ // the ledger would have been at its cleanest exactly while an agent was silent.
678
+ const everHere = new Set([...present])
679
+ for (const m of s.hive.doc.getArray('chat').toArray()) {
680
+ if (m && m.by) everHere.add(String(m.by).toLowerCase())
681
+ }
682
+ const opened = targets.map((n) => OB.open(obMap, {
683
+ from: s.name, to: n, text: body, dueMs,
684
+ absent: !present.has(n.toLowerCase()),
685
+ neverSeen: !everHere.has(n.toLowerCase()),
686
+ }))
687
+ if (opened.length) {
688
+ obStr = `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
689
+ `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
690
+ `a reply does not close it either — you decide whether you were actually answered. If it ` +
691
+ `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
692
+ const ghosts = opened.filter((o) => o.absent)
693
+ if (ghosts.length) {
694
+ obStr += `\n\n⚠ ${ghosts.map((o) => '@' + o.to).join(', ')} — NOT IN THIS ROOM. Nobody by that ` +
695
+ `name is here, so nobody is going to answer. Call hive_members and re-address it now; ` +
696
+ `this is the one failure mode that otherwise costs you a whole shift.`
697
+ }
698
+ }
699
+ }
700
+ return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
701
+ }
702
+ case 'hive_busy': {
703
+ const s = requireSession()
704
+ const busyMap = s.hive.doc.getMap('busy')
705
+ if (args.clear) { busyMap.delete(s.name); return text('busy cleared — the room will expect normal replies from you again') }
706
+ const e = makeBusy(args.what, { ttl: args.minutes ? args.minutes * 60000 : undefined })
707
+ busyMap.set(s.name, e)
708
+ // Say what it does NOT do, because an agent that believes this silences its mail will
709
+ // stop calling hive_check and go deaf on purpose.
710
+ return text(`marked busy: ${e.what}\n\nThis posts no message and silences nothing — you still ` +
711
+ `receive mail and must still call hive_check between edits. It only tells anyone who ` +
712
+ `@mentions you, at the moment they send, that you are mid-task. It expires by itself in ` +
713
+ `${Math.round(e.ttl / 60000)} minutes, so a crash cannot leave the room believing you are ` +
714
+ `permanently busy. Call hive_busy with clear:true when you finish.`)
472
715
  }
473
716
  case 'hive_read_chat': {
474
717
  const me = requireSession().name
@@ -496,7 +739,22 @@ async function dispatch(req) {
496
739
  const got = args.peek ? box.peek(all) : box.take(all)
497
740
  if (!args.peek) persistCursor()
498
741
  const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
499
- if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
742
+ // WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
743
+ // moment a message is handed over, so an agent can absorb a direct question, get busy,
744
+ // and never notice it left someone hanging -- which is the failure that made a previous
745
+ // room's PM give up on this channel and tail the chat file with a shell script instead.
746
+ // Derived from the log, so it needs no state and cannot drift.
747
+ const owed = unansweredFor(all, s.name)
748
+ const owedLine = owed.length
749
+ ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
750
+ `Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
751
+ `silence is the one thing they cannot act on.`
752
+ : ''
753
+ if (!got.unreadCount && !tasks.length) {
754
+ return text(owed.length
755
+ ? `✓ no new mail and no approved work.${owedLine}`
756
+ : '✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
757
+ }
500
758
  const parts = []
501
759
  if (got.priority === 'stop') {
502
760
  // HANDLING URGENT MAIL COUNTS AS LIVENESS.
@@ -529,7 +787,7 @@ async function dispatch(req) {
529
787
  const dig = ambientDigest(ambient)
530
788
  if (dig) parts.push(dig)
531
789
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
532
- return text(parts.join('\n\n'))
790
+ return text(parts.join('\n\n') + owedLine)
533
791
  }
534
792
  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)') }
535
793
  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') }
@@ -541,9 +799,23 @@ async function dispatch(req) {
541
799
  }
542
800
  case 'hive_wait': {
543
801
  const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
544
- const r = await waitForWork(ms, { wakeOn: args.wakeOn })
802
+ const r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
545
803
  const globalState = requireSession().hive.getState()
546
- const stateStr = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
804
+ // THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
805
+ // sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
806
+ // it each time. It is now sent when the agent has not seen it, when it CHANGED (which
807
+ // always wins: a suppressed state change is a wrong answer, not a saving), or when enough
808
+ // time AND enough output have passed that it is probably no longer in context.
809
+ const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
810
+ const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
811
+ // The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
812
+ // compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
813
+ // ownership -- so the general rules in the system prompt do not cover them. A pointer is
814
+ // re-sent, never a copy: the file is on this agent's own disk, and a second summary here
815
+ // would be one more description to drift out of date.
816
+ const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
817
+ ? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
818
+ : ''
547
819
  // Connection state, reported ONLY when it is bad. An agent that cannot tell a
548
820
  // quiet room from a dead socket reaches for hive_join -- which calls stop() and
549
821
  // tears the session down, turning a blip that would have healed itself into a
@@ -558,6 +830,25 @@ async function dispatch(req) {
558
830
  : ''
559
831
 
560
832
  const me = requireSession().name
833
+ // THE KNOCK RIDES THE WAIT. hive_wait is the agent's idle state and a blocked wait costs
834
+ // zero tokens, so a reminder delivered here arrives on a call that was going to happen
835
+ // anyway. Agent Relay re-injects its boomerang on a broker timer, which spends a turn on
836
+ // every knock; this spends nothing. The reminder is deliberately NOT sent to a paused
837
+ // agent -- paused means stop, and nagging a stopped agent is just noise.
838
+ const obMap = (() => { try { return requireSession().hive.doc.getMap('obligations') } catch { return null } })()
839
+ let owedStr = ''
840
+ if (obMap) {
841
+ const due = OB.overdue(obMap, me)
842
+ if (due.length) {
843
+ // Only a DELIVERED knock moves the deadline. If merely NOTICING an overdue record
844
+ // rescheduled it, an agent that never calls hive_wait would have its deadline pushed
845
+ // out forever by whoever happened to read the board -- exactly backwards.
846
+ for (const o of due) OB.knock(obMap, o.id)
847
+ owedStr = `\n[YOU OWE ${due.length} ANSWER(S)] These were asked of you and are still open. ` +
848
+ `Answer in chat with @Name — you cannot close them yourself, the asker does that.\n` +
849
+ due.map((o) => ' ' + OB.describe(o)).join('\n') + '\n'
850
+ }
851
+ }
561
852
  // Direct address is separated from ambient traffic on every path. It used to be that
562
853
  // only a PAUSED agent got this distinction, so a working agent could not tell "stop,
563
854
  // wrong file" from two other agents talking to each other.
@@ -577,13 +868,67 @@ async function dispatch(req) {
577
868
  }
578
869
  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}`)
579
870
  }
580
- if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${connStr}${stateStr}`)
871
+ if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${owedStr}${connStr}${stateStr}${rulesStr}`)
581
872
  const parts = []
582
873
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
583
874
  const mb = mentionBlock(r); if (mb) parts.push(mb)
584
875
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
585
876
  const ab = ambientBlock(r); if (ab) parts.push(ab)
586
- return text(`${connStr}${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
877
+ return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
878
+ }
879
+ case 'hive_delivered': {
880
+ const s2 = requireSession()
881
+ const conn = (() => { try { return s2.hive.status() } catch { return null } })()
882
+ const connected = !conn || !!conn.connected
883
+ const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
884
+ const rs = RC.receipts({
885
+ chat: s2.hive.doc.getArray('chat').toArray(),
886
+ cursors: s2.hive.doc.getMap('cursors'),
887
+ me: s2.name,
888
+ present: (n) => here.has(String(n).toLowerCase()),
889
+ connected,
890
+ limit: Math.min(Math.max(Number(args.limit) || 10, 1), 50),
891
+ })
892
+ if (!rs.length) return text('You have not addressed anyone recently, so there is nothing to confirm. (Unaddressed and @all messages are not tracked — nobody promised to read those.)')
893
+ const states = rs.flatMap((r) => r.to)
894
+ const read = RC.countDelivered(states)
895
+ const unknown = states.filter((x) => x.state === RC.UNKNOWN).length
896
+ // The headline deliberately reports READ over TOTAL and names UNKNOWN separately rather
897
+ // than folding it in. Summing "not proven undelivered" into the good number is how a
898
+ // receipt system starts reassuring instead of measuring.
899
+ const head = `${read}/${states.length} confirmed READ` +
900
+ (unknown ? ` — ${unknown} UNKNOWN (no read position published; that is an absence of evidence, not evidence of delivery)` : '')
901
+ const worst = states.filter((x) => x.state !== RC.READ)
902
+ return text(`${head}\n\n` + rs.map((r) => ' ' + RC.describe(r)).join('\n') +
903
+ (worst.length ? '\n\n' + [...new Set(worst.map((x) => `${x.name}: ${x.why}`))].join('\n') : ''))
904
+ }
905
+ case 'hive_answered': {
906
+ const s2 = requireSession()
907
+ const obMap = s2.hive.doc.getMap('obligations')
908
+ if (args.from) {
909
+ const closed = OB.dischargeFrom(obMap, { by: s2.name, to: String(args.from) })
910
+ if (!closed.length) return text(`Nothing open that you asked @${args.from}. hive_owed shows what is.`)
911
+ return text(`Closed ${closed.length} question(s) you asked @${args.from}:\n` + closed.map((o) => ' ' + o.text).join('\n'))
912
+ }
913
+ if (!args.id) return err('hive_answered needs either id (one question) or from (everything you asked one agent). hive_owed lists both.')
914
+ const r2 = OB.discharge(obMap, { id: String(args.id), by: s2.name })
915
+ if (!r2.ok) return err(r2.reason)
916
+ return text(r2.already ? `${args.id} was already closed.` : `Closed ${args.id}: "${r2.rec.text}"`)
917
+ }
918
+ case 'hive_owed': {
919
+ const s2 = requireSession()
920
+ const obMap = s2.hive.doc.getMap('obligations')
921
+ if (args.all) {
922
+ const b = OB.board(obMap)
923
+ if (!b.open) return text('Nothing outstanding in this room.')
924
+ return text(`${b.open} open, ${b.stale} stale:\n` + b.lines.map((l) => ' ' + l).join('\n'))
925
+ }
926
+ const mine = OB.owedBy(obMap, s2.name), theirs = OB.owedTo(obMap, s2.name)
927
+ if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
928
+ const parts = []
929
+ if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + OB.describe(o)).join('\n'))
930
+ if (theirs.length) parts.push(`YOU ARE OWED ${theirs.length} — close with hive_answered once you actually have what you needed:\n` + theirs.map((o) => ' ' + OB.describe(o)).join('\n'))
931
+ return text(parts.join('\n\n'))
587
932
  }
588
933
  case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
589
934
  case 'hive_read_state': { return text(requireSession().hive.getState()) }
@@ -601,9 +946,29 @@ async function dispatch(req) {
601
946
  case 'hive_verified': {
602
947
  const f = String(args.file || ''); if (!f) return err('need a file path')
603
948
  const r = requireSession().hive.verified(f)
604
- if (!r.count) return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.`)
949
+ // Attestation and gate verdicts are independent records, so an unattested path must still
950
+ // report its gate. Returning early here would hide a FAILED check behind "nobody measured
951
+ // the bytes", which is the opposite of what this tool is for.
952
+ if (!r.count) {
953
+ const g0 = requireSession().hive.gateVerdicts(f)
954
+ const extra = Object.entries(g0).map(([n, v]) => v.forced
955
+ ? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
956
+ : `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
957
+ return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
958
+ }
605
959
  const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
606
- 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.'}`)
960
+ // Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
961
+ // WORKS, which is the actual question an agent is asking here — so the gate verdicts
962
+ // belong in this answer. A file two peers agree on and whose owner's tests failed is the
963
+ // exact thing somebody is about to build on.
964
+ const g = requireSession().hive.gateVerdicts(f)
965
+ const gLines = Object.entries(g).map(([n, v]) => v.forced
966
+ ? ` [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ' and gave no reason'}`
967
+ : ` ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}: ${v.cmd}`)
968
+ const gate = gLines.length
969
+ ? `\n\nCHECKS RUN BY THE AGENTS THAT HELD IT:\n${gLines.join('\n')}${Object.values(g).some((v) => !v.pass) ? '\n Somebody handed this back knowing it did not pass. Read it before you build on it.' : ''}`
970
+ : '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
971
+ return text(`${f}\n ${who}\n\n${r.diverged ? '[!] DIVERGED — peers hold different bytes. Do not write or build on this path.' : r.quorum ? 'QUORUM: confirmed by 2+ peers at the same size.' : 'Attested by ONE peer only — not yet proof.'}${gate}`)
607
972
  }
608
973
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
609
974
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
@@ -614,18 +979,52 @@ async function dispatch(req) {
614
979
  // always a mistake, and refusing here is far cheaper than refusing after the agent has
615
980
  // built the content it intended to write.
616
981
  enforceDcoLock({ path: region });
617
- const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
982
+ // HIV-27: A CLAIM YOU CANNOT PUBLISH IS NOT A CLAIM. The claim is a path string in the
983
+ // shared map; whether that path's BYTES sync is decided separately, per client. Nothing
984
+ // compared them, so an agent could hold an exclusive claim on a file nobody else can
985
+ // see, do the work, pass a gate running in its own folder where the file is real, and
986
+ // hand back something that never existed for anyone else -- every signal green.
987
+ // Refused HERE rather than at release: by release time the work is already built and
988
+ // all we could do is tell the agent it was wasted.
989
+ const unshared = requireSession().hive.whyNotShared(region)
990
+ if (unshared) {
991
+ return err(`refusing to claim ${region} — ${unshared}
992
+
993
+ Nothing was claimed. Put the path inside the room's sync scope, or pick one that is already in it, BEFORE you start work: a claim whose bytes never leave this folder is indistinguishable from one that works.`)
994
+ }
995
+ // SETTLED, not optimistic. The synchronous claim answers from this replica alone and
996
+ // tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
997
+ // behind a lock it does not hold. This waits for the relay to serialise it.
998
+ const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
618
999
  if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
619
1000
  const c = requireSession().hive.senseClaim(region)
620
1001
  return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
621
1002
  }
622
- case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
1003
+ case 'hive_release': {
1004
+ const region = String(args.region || '')
1005
+ if (!region) return err('need a region')
1006
+ // A gated release can REFUSE, and reporting "released" either way is the worst thing this
1007
+ // could do: the agent moves on believing it handed the file back while it still holds it.
1008
+ const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
1009
+ if (r && r.ok === false) return text(r.message)
1010
+ if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room`)
1011
+ return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}`)
1012
+ }
623
1013
  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)') }
624
1014
  case 'hive_edit': {
625
1015
  const editPath = String(args.file || '')
626
1016
  // Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
627
1017
  // indistinguishable from no lease at all.
628
1018
  enforceDcoLock({ consumes: true, path: editPath })
1019
+ // HIV-27, same reasoning: this tool's entire promise is that the change reaches every
1020
+ // peer. On a path this client never opens a connection for, it would not -- and it
1021
+ // would still report success.
1022
+ const unshared = requireSession().hive.whyNotShared(editPath)
1023
+ if (unshared) {
1024
+ return err(`refusing to write ${editPath} through the medium — ${unshared}
1025
+
1026
+ Nothing was written. This tool exists to guarantee the change syncs to every peer; on this path it cannot.`)
1027
+ }
629
1028
  const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
630
1029
  if (r.error) return err(r.error)
631
1030
  return text(r.conflicted
@@ -741,7 +1140,44 @@ function mailBanner() {
741
1140
  } catch { return '' }
742
1141
  }
743
1142
 
1143
+ // A MISSING REQUIRED ARGUMENT MUST BE REFUSED, NOT WRITTEN AS `undefined`.
1144
+ //
1145
+ // Nothing was validating `inputSchema.required`. The SDK does not enforce it, and every handler
1146
+ // read its arguments straight off the object, so a near-miss parameter name sailed through as
1147
+ // `undefined` and the tool reported success. Two real cases, both on 18 Sep 2026:
1148
+ // hive_say({ message }) -> posted an EMPTY message and answered "sent: "
1149
+ // hive_set_state({ value }) -> answered "Set global state [gate:cmd] to [undefined]", which
1150
+ // overwrote the room's gate command with the string "undefined"
1151
+ // and would have broken the handoff gate for every agent.
1152
+ // The failure mode is what makes it serious: the agent is told the action happened. It moves on,
1153
+ // and the room is left holding a corrupted value nobody wrote on purpose. Near-miss names are
1154
+ // the common case, so the refusal names the unknown argument that was supplied too.
1155
+ function missingRequired(req) {
1156
+ const name = req && req.params && req.params.name
1157
+ const tool = TOOLS.find((t) => t.name === name)
1158
+ const schema = tool && tool.inputSchema
1159
+ const required = schema && Array.isArray(schema.required) ? schema.required : []
1160
+ if (!required.length) return ''
1161
+ const args = (req.params && req.params.arguments) || {}
1162
+ // undefined/null only: '' and 0 and false are legitimate values a caller may mean.
1163
+ const missing = required.filter((k) => args[k] === undefined || args[k] === null)
1164
+ if (!missing.length) return ''
1165
+ const known = Object.keys(schema.properties || {})
1166
+ const unknown = Object.keys(args).filter((k) => !known.includes(k))
1167
+ const lines = [
1168
+ `REFUSED -- ${name} needs ${missing.map((m) => `\`${m}\``).join(', ')} and it was not supplied.`,
1169
+ `Nothing was written. This call did NOT happen.`,
1170
+ ]
1171
+ if (unknown.length) {
1172
+ lines.push(`You passed ${unknown.map((u) => `\`${u}\``).join(', ')}, which ${name} does not take -- most likely you meant ${missing.map((m) => `\`${m}\``).join(', ')}.`)
1173
+ }
1174
+ lines.push(`${name} accepts: ${known.map((k) => (required.includes(k) ? `${k} (required)` : k)).join(', ')}.`)
1175
+ return lines.join('\n')
1176
+ }
1177
+
744
1178
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
1179
+ const bad = missingRequired(req)
1180
+ if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
745
1181
  const res = await dispatch(req)
746
1182
  // Never let the banner break a tool: append only to a well-formed text result, and skip the two
747
1183
  // tools that already deliver mail themselves (appending would double-report the same messages).
@@ -771,6 +1207,10 @@ if (isEntryPoint) {
771
1207
  export const _test = {
772
1208
  waitForWork,
773
1209
  mailbox,
1210
+ // Exposed so the throttle's saving is asserted against the counter itself rather than inferred
1211
+ // from what happened not to appear in a tool result. Counting absences is how you prove a thing
1212
+ // is quiet, not how you prove it is working.
1213
+ throttleStats: () => throttle.stats(),
774
1214
  setSession(s) { session = s },
775
1215
  getSession() { return session },
776
1216
  callTool: (name, args = {}) =>