hivecode-mcp 0.11.5 → 0.12.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
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Register it with an MCP client (e.g. Claude Code / Claude Desktop):
6
6
  // command: node args: ["<abs-path>/hive-mcp.js"]
7
- // env (optional): HIVE_RELAY = wss://your-relay (defaults to the hosted relay)
7
+ // env (optional): HIVE_RELAY = wss://your-relay (defaults to PUBLIC_DEFAULT_RELAY in hive-privacy.js)
8
8
  //
9
9
  // Tools exposed:
10
10
  // hive_join join/host a room for a folder (auto kind:ai) -> returns the rules
@@ -34,12 +34,33 @@ 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 { checkRelay, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
38
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
39
+ import { INSTRUCTIONS } from './hive-instructions.js'
40
+ import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
41
+ import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
42
+ import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
43
+ import * as OB from './hive-obligations.js'
44
+ import * as RC from './hive-receipts.js'
45
+ import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
38
46
 
39
- const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
47
+ const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
40
48
  let session = null // { hive, room, relay, dir, name, mailbox }
41
49
 
42
- const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] })
50
+ // ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
51
+ // session: a rejoin does not empty the agent's context, so resetting the throttle there would
52
+ // re-send everything the agent can still see -- the exact waste this removes.
53
+ const throttle = makeThrottle()
54
+
55
+ const text = (s) => {
56
+ const body = typeof s === 'string' ? s : JSON.stringify(s, null, 2)
57
+ // THE METER. Every tool result leaves through here, so this count is exact rather than
58
+ // sampled -- it is the local equivalent of the PTY output bytes Agent Relay's broker measures,
59
+ // and it is the only evidence available that an agent has done enough since it last saw a
60
+ // block for that block to have been compacted away.
61
+ try { throttle.note(body.length) } catch { /* the meter must never break a tool result */ }
62
+ return { content: [{ type: 'text', text: body }] }
63
+ }
43
64
  const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
44
65
 
45
66
  function resolveRoom(link, dir) {
@@ -69,6 +90,12 @@ function readSessionFile() {
69
90
  function persistCursor() {
70
91
  if (!session || !session.mailbox) return
71
92
  persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
93
+ // AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
94
+ // what makes "did they actually read it" answerable by anybody other than me. The cursor only
95
+ // advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
96
+ // past a message is the one honest piece of evidence this system has that the message reached
97
+ // somebody's context. Before this, that evidence existed and was thrown away on disk.
98
+ try { RC.publishCursor(session.hive.doc.getMap('cursors'), session.name, session.mailbox.cursor) } catch { /* a room that predates receipts */ }
72
99
  }
73
100
  async function tryResumeSession() {
74
101
  try {
@@ -78,11 +105,40 @@ async function tryResumeSession() {
78
105
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
79
106
  }
80
107
 
81
- async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
108
+ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
109
+ // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
110
+ // exists, so validating after that point would leave an agent with a bad name holding nothing at
111
+ // all -- it would lose a working room to a refusal it could have been given for free.
112
+ const nameCheck = validateMemberName(name)
113
+ if (!nameCheck.ok) {
114
+ return `${nameCheck.error}
115
+
116
+ Nothing was joined and your current session is untouched. Re-run hive_join with name: "${nameCheck.suggested}".
117
+
118
+ 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.`
119
+ }
82
120
  if (session) { try { session.hive.stop() } catch {} ; session = null }
83
121
  const ROOT = path.resolve(dir)
84
122
  fs.mkdirSync(ROOT, { recursive: true })
85
123
  const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
124
+ // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
125
+ // .hive.json write below and before startSync opens a socket. A refused join must leave
126
+ // nothing behind -- no config pointing at the wrong relay for the next process to find,
127
+ // and above all no published file. Checking after connecting would report a leak instead
128
+ // of preventing one, which is the difference between a guard and a log line.
129
+ const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
130
+ if (!priv.ok) {
131
+ return `${priv.headline}
132
+
133
+ ${priv.detail}
134
+
135
+ room: ${room}
136
+ mode: ${mode}
137
+ folder: ${ROOT}
138
+
139
+ NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
140
+ was not written. Fix the relay and call hive_join again.`
141
+ }
86
142
  // token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
87
143
  const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
88
144
  // persist the token too, so a re-join from .hive.json keeps the grant (the
@@ -98,7 +154,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
98
154
  // folder is precisely the situation the lock exists to prevent.
99
155
  let hive
100
156
  try {
101
- hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
157
+ hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
102
158
  } catch (e) {
103
159
  if (e && e.code === 'EHIVELOCKED') {
104
160
  return text(
@@ -108,15 +164,76 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
108
164
  }
109
165
  throw e
110
166
  }
111
- session = { hive, room, relay, dir, name, owner, mailbox: null }
167
+ session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
112
168
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
113
- // wait for the first sync so members/board/rules are ready
169
+ // Wait for the first sync so members/board/rules are ready.
170
+ //
171
+ // REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
172
+ // caller returned a cheerful "joined" banner whether or not the relay had accepted us. On a
173
+ // relay at capacity that is a false success report: the agent is told it is in a room, reads
174
+ // the rules, claims files and writes code, and NONE of it reaches anybody. It happened for
175
+ // real -- a relay answered 38,011 connections with Q430 while three agents believed they were
176
+ // collaborating. An agent cannot detect this on its own; the only honest place to catch it is
177
+ // here, at the moment we know whether the socket came up.
178
+ let synced = false
179
+ let connErr = ''
180
+ let refusedCode = null
114
181
  await new Promise((resolve) => {
115
182
  let done = false
116
183
  const finish = () => { if (!done) { done = true; resolve() } }
117
- hive.provider.on('sync', (s) => s && finish())
184
+ hive.provider.on('sync', (s) => { if (s) { synced = true; finish() } })
185
+ // A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
186
+ // Q-code and destroys the socket, so this event is the only place the reason is visible.
187
+ hive.provider.on('connection-error', (ev) => {
188
+ const code = RH.statusFromError(ev) || (ev && (ev.code || ev.status || ev.type))
189
+ if (code && typeof code === 'number') refusedCode = code
190
+ if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
191
+ })
118
192
  setTimeout(finish, 8000) // don't hang if the relay is cold
119
193
  })
194
+ if (!synced) {
195
+ // HIV-P12. EVIDENCE BEFORE INFERENCE.
196
+ // If the relay actually refused us with an HTTP status, explainStatus() maps it to the
197
+ // authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
198
+ // Only when nothing refused us with a code do we query /health to infer whether it was capacity.
199
+ if (refusedCode) {
200
+ const explanation = RH.explainStatus(refusedCode, relay)
201
+ return [
202
+ `NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
203
+ `room: ${room}`,
204
+ `relay: ${relay}`,
205
+ `folder: ${ROOT}`,
206
+ '',
207
+ 'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
208
+ 'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
209
+ 'you do now is invisible to the team and can be silently overwritten later.',
210
+ '',
211
+ 'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
212
+ 'it reports `connected` and `filesSynced`, and both are authoritative.',
213
+ ].join('\n')
214
+ }
215
+ const why = connErr || 'no sync within 8s -- the relay did not answer'
216
+ const diag = await RH.explainNotSyncing(relay, { what: 'join' })
217
+ return [
218
+ `NOT JOINED -- ${why}.`,
219
+ `room: ${room}`,
220
+ `relay: ${relay}`,
221
+ `folder: ${ROOT}`,
222
+ '',
223
+ 'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
224
+ 'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
225
+ 'you do now is invisible to the team and can be silently overwritten later.',
226
+ '',
227
+ diag.text,
228
+ '',
229
+ 'Two causes the relay cannot tell you about, worth checking if the above says capacity is fine:',
230
+ ' - the relay is not running, or the port/room in the link is wrong.',
231
+ ' - the room is secured and this link carries no token (Q4xx on connect).',
232
+ '',
233
+ 'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
234
+ 'it reports `connected` and `filesSynced`, and both are authoritative.',
235
+ ].join('\n')
236
+ }
120
237
  // The mailbox is created AFTER the first sync, so chat.length is the real room history and
121
238
  // not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
122
239
  // A resumed session carries its saved cursor forward instead, so anything posted while this
@@ -130,8 +247,18 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
130
247
 
131
248
  // Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
132
249
  hive.doc.getMap('swarm_state').observe(() => {
250
+ // SAME DEFECT AS THE MAIL DOORBELL BELOW, AND FOUND THE SAME WAY -- a test crashed inside a
251
+ // Yjs observer with the stack pointing at a `try` that looked like it had the case covered.
252
+ // `notification()` is async: a failure is a REJECTED PROMISE, which sails past this catch,
253
+ // becomes an unhandled rejection and takes the whole agent down.
254
+ //
255
+ // Worth recording that the first fix only repaired the copy in the chat observer. This one
256
+ // survived untouched for another two tickets and then crashed a second test -- which is the
257
+ // duplicated-wire-contract lesson arriving in person: repair the CLASS, not the instance.
258
+ // Both call sites are now the same shape, deliberately.
133
259
  try {
134
- server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } })
260
+ Promise.resolve(server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } }))
261
+ .catch(() => { /* no transport, or a client that does not take notifications */ })
135
262
  } catch (e) {
136
263
  // client might not support notifications, swallow error
137
264
  }
@@ -164,7 +291,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
164
291
  // 0, and a later single mention must still ring. `>` would go permanently silent.
165
292
  if (!m.mentioned || m.mentions.length === lastMailSignal) return
166
293
  lastMailSignal = m.mentions.length
167
- server.notification({
294
+ // THE CATCH BELOW DOES NOT COVER THIS CALL. `notification()` is async, so a failure inside
295
+ // it is a REJECTED PROMISE, not a thrown exception -- it sails past the surrounding
296
+ // try/catch, becomes an unhandled rejection, and takes the whole agent down. Observed
297
+ // 19 Sep 2026: a second agent posting chat to a server whose transport was not connected
298
+ // crashed the process from inside this Yjs observer, with the stack pointing at a `try`
299
+ // that looked like it had the case covered.
300
+ //
301
+ // A doorbell that can kill the agent is strictly worse than no doorbell -- and this one is
302
+ // already optional by design (custom MCP notifications are ignorable by spec), so the right
303
+ // behaviour on any failure is silence.
304
+ Promise.resolve(server.notification({
168
305
  method: 'notifications/hive_mail',
169
306
  params: {
170
307
  mentioned: true,
@@ -174,7 +311,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
174
311
  latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
175
312
  hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
176
313
  },
177
- })
314
+ })).catch(() => { /* no transport, or a client that does not take notifications */ })
178
315
  } catch (e) {
179
316
  // client might not support notifications, swallow error
180
317
  }
@@ -189,17 +326,37 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
189
326
  // The invite link carries the token when there is one — without it a secured room rejects
190
327
  // whoever you hand it to, and the Control Room says the link is missing its token.
191
328
  const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
192
- 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}`
329
+ // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
330
+ // who has not run a relay -- the public one and a company's own look identical. The
331
+ // bracketed verdict is what makes "did my source just leave the building?" answerable
332
+ // without asking, and the warning line below is what makes it actionable.
333
+ const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
334
+ return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
193
335
  }
194
336
 
195
337
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
196
338
  /** The read cursor for this participant. Lazily built if a session predates it. */
197
339
  function mailbox() {
198
340
  const s = requireSession()
341
+ // hive_join has just handed over the full HIVE_RULES.md, so the nudge would be pure noise on
342
+ // the next wait. Marking it sent here is what makes the join and the reminder one mechanism
343
+ // rather than two that disagree.
344
+ try { throttle.sent('rules', RULES_NUDGE) } catch { }
199
345
  if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
200
346
  return s.mailbox
201
347
  }
202
348
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
349
+
350
+ // HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
351
+ // observation about a tool call, not part of it, and it must never be the reason a tool fails.
352
+ function activityMap() { try { return requireSession().hive.doc.getMap('activity') } catch { return null } }
353
+ // Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
354
+ // keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
355
+ function findBusy(map, name) {
356
+ const want = String(name || '').toLowerCase()
357
+ for (const [k, v] of map.entries()) if (String(k).toLowerCase() === want) return v
358
+ return null
359
+ }
203
360
  function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
204
361
  function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
205
362
  const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.decidedBy ? ` (by ${t.decidedBy})` : ''}`
@@ -207,6 +364,32 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
207
364
  // Block until there is approved work for me (or a new chat message), then return.
208
365
  // This is how the agent reacts the INSTANT the owner approves — no polling loop,
209
366
  // no interval to tune. Resolves immediately if approved work already exists.
367
+ /** WAKE ON STATE — the predicate that lets a roster run itself.
368
+ *
369
+ * Without this, hive_wait only returns on work assigned TO you or a message naming you, so every
370
+ * multi-agent roster silently depends on agents remembering to @ each other after each step. The
371
+ * rules ASK them to (sync.js: "block on hive_wait until your specific state flag turns
372
+ * APPROVED/READY") and nothing MAKES them, so a night roster stalls with no error: the reviewer
373
+ * is asleep, the tests went green an hour ago, and nobody said so.
374
+ *
375
+ * Shapes, all on one state key:
376
+ * { key: 'tests', is: 'GREEN' } exactly this value
377
+ * { key: 'tests', in: ['GREEN', 'SKIP'] } any of these
378
+ * { key: 'tests', not: 'RED' } anything except this (INCLUDING unset)
379
+ * { key: 'tests' } any value at all, i.e. the key exists
380
+ *
381
+ * `not` deliberately matches an unset key. A reviewer told "wake unless the build is RED" should
382
+ * wake on a fresh room where nobody has set `build` yet -- treating absent as RED would hang it
383
+ * forever waiting for a flag no one is going to write. */
384
+ export function stateMatches(until, get) {
385
+ if (!until || !until.key) return false
386
+ const v = get(until.key)
387
+ if (Object.prototype.hasOwnProperty.call(until, 'is')) return String(v) === String(until.is)
388
+ if (Array.isArray(until.in)) return until.in.some((x) => String(x) === String(v))
389
+ if (Object.prototype.hasOwnProperty.call(until, 'not')) return String(v) !== String(until.not)
390
+ return v !== undefined && v !== null
391
+ }
392
+
210
393
  function waitForWork(timeoutMs, opts = {}) {
211
394
  const s = requireSession()
212
395
  const tmap = s.hive.doc.getMap('tasks')
@@ -251,6 +434,13 @@ function waitForWork(timeoutMs, opts = {}) {
251
434
  }
252
435
  // Queued work no longer hides the mail. This returned `newChat: []` outright, which made
253
436
  // the busiest agent in the room the one guaranteed to see no messages at all.
437
+ // A state the caller is waiting for, checked BEFORE anything blocks: if it is already true the
438
+ // agent must not sit through a timeout waiting to be told something that happened an hour ago.
439
+ const state = s.hive.doc.getMap('swarm_state')
440
+ const until = opts.until && opts.until.key ? opts.until : null
441
+ const stateHit = () => stateMatches(until, (k) => state.get(k))
442
+ if (until && stateHit()) return Promise.resolve(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } }))
443
+
254
444
  const ready = accepted()
255
445
  if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
256
446
  // Anything already unread is handed over immediately rather than waited past — that backlog
@@ -261,7 +451,7 @@ function waitForWork(timeoutMs, opts = {}) {
261
451
  }
262
452
  return new Promise((resolve) => {
263
453
  let done = false
264
- const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
454
+ 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
455
  const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
266
456
  const onChat = () => {
267
457
  const seen = box.peek(chat.toArray())
@@ -270,7 +460,11 @@ function waitForWork(timeoutMs, opts = {}) {
270
460
  finish(deliver({ tasks: accepted() }))
271
461
  }
272
462
  const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
273
- tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl)
463
+ // A paused agent is NOT woken by state: pausing means stop picking up work, and a state flag
464
+ // turning green is the room offering it work. It re-checks the predicate on its next wait
465
+ // after a human resumes it.
466
+ const onState = () => { if (until && !paused() && stateHit()) finish(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } })) }
467
+ tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
274
468
  // On timeout, flush anything that arrived rather than dropping it: a message must never
275
469
  // expire unread just because the wait window closed on it.
276
470
  setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
@@ -279,13 +473,13 @@ function waitForWork(timeoutMs, opts = {}) {
279
473
 
280
474
  const TOOLS = [
281
475
  { name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
282
- inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' } } } },
476
+ inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
283
477
  { name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
284
478
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
285
479
  { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
286
480
  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." } } } },
481
+ { 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.',
482
+ 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
483
  { 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
484
  inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
291
485
  { 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 +488,28 @@ const TOOLS = [
294
488
  inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
295
489
  { name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
296
490
  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'] } },
491
+ { 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.',
492
+ 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'] } },
493
+ { 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.',
494
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
495
+ { 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.',
496
+ 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' } } } },
497
+ { 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.',
498
+ inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
499
+ { 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.',
500
+ 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
501
  { 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
502
  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
503
  { 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
504
  inputSchema: { type: 'object', properties: {} } },
303
505
  { 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
506
  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'] } },
507
+ { 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).',
508
+ inputSchema: { type: 'object', properties: {
509
+ region: { type: 'string', description: 'the file path (or region id) to release' },
510
+ 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.' },
511
+ 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.' },
512
+ }, required: ['region'] } },
307
513
  { 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
514
  inputSchema: { type: 'object', properties: {} } },
309
515
  { 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.',
@@ -432,14 +638,17 @@ function enforceDcoLock(opts = {}) {
432
638
  // itself reporting the wrong build. Read the truth instead; package.json always ships in the
433
639
  // tarball and sits beside this file in both the repo and the published package.
434
640
  // A failed read must never stop the server from starting, so it falls back rather than throwing.
435
- export const VERSION = (() => {
436
- try {
437
- const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
438
- if (typeof v === 'string' && v) return v
439
- } catch { /* fall through */ }
440
- return '0.0.0-unknown'
441
- })()
442
- const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
641
+ import { VERSION } from "./hive-version.js"
642
+ export { VERSION }
643
+ // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
644
+ // makes it the only channel that reaches an agent before its first tool call. Everything else we
645
+ // have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
646
+ // away, and the rule that matters most (hive_wait is your idle state; ending your turn makes you
647
+ // unreachable) is exactly the one an agent needs up front. See hive-instructions.js.
648
+ const server = new Server(
649
+ { name: 'hivecode', version: VERSION },
650
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS },
651
+ )
443
652
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
444
653
  async function dispatch(req) {
445
654
  const { name, arguments: args = {} } = req.params
@@ -449,9 +658,32 @@ async function dispatch(req) {
449
658
  // persisted session exists for this cwd, transparently rejoin before any tool runs —
450
659
  // the agent (and its human) never see a "not in a room" flake again.
451
660
  if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
661
+ // HIV-P11. STAMPED BEFORE THE TOOL RUNS, not after it returns.
662
+ //
663
+ // The first version stamped in a `finally` -- which is wrong by exactly one call, and the
664
+ // suite caught it: an agent that called hive_members saw ITSELF reported PARKED, because the
665
+ // record it read still held the timestamp of its previous call. The activity is the call
666
+ // BEGINNING; a tool that takes four minutes is working for all four of them, and a tool that
667
+ // throws was still an agent doing something. Stamping first covers both, and covers every
668
+ // tool anyone adds later, because there is no path into the switch that skips this line.
669
+ //
670
+ // hive_wait stamps its own entry and exit (waitingSince must mean "inside the call RIGHT
671
+ // NOW"). hive_leave drops the row instead of refreshing it. hive_join has no session yet, so
672
+ // it is stamped in the finally below -- the one case where after is the only option.
673
+ if (session && name !== 'hive_wait' && name !== 'hive_leave') {
674
+ try { ACT.noteAction(activityMap(), { name: session.name, tool: name }) } catch { }
675
+ }
452
676
  switch (name) {
453
677
  case 'hive_join': return text(await joinRoom(args))
454
678
  case 'hive_say': {
679
+ // THE CONNECTION IS CHECKED BEFORE ANYTHING ELSE. Until today this tool reported
680
+ // "sent to @Builder: ..." with the socket down, which is a false success in the most
681
+ // literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
682
+ // word the agent reads is the same word it reads on success. status().connected was
683
+ // right there and free the whole time.
684
+ const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
685
+ const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
686
+ const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
455
687
  const raw = String(args.text || '')
456
688
  // The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
457
689
  // a human reading the log, not just to the recipient's tool output.
@@ -463,12 +695,164 @@ async function dispatch(req) {
463
695
  // Immediate, per-call feedback, at the moment the mistake is made.
464
696
  const named = mentionedNames(body)
465
697
  if (!named.length) {
466
- return text(`sent: ${body}\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
698
+ return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
467
699
  `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
468
700
  `agents you actually need with @Name (hive_members lists who is here). Use an ` +
469
701
  `unaddressed message only for a genuine announcement to the whole room.`)
470
702
  }
471
- return text(`sent to ${named.map((n) => '@' + n).join(', ')}: ${body}`)
703
+ // WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
704
+ // "they left" or "they are mid-task", and it is worth strictly more here than in a
705
+ // reply: it arrives before the sender starts waiting, and it costs the room no message.
706
+ // This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
707
+ const s = requireSession()
708
+ const members = s.hive.members()
709
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
710
+ const present = new Set(members.map((m) => String(m.name).toLowerCase()))
711
+ const busyMap = s.hive.doc.getMap('busy')
712
+ const act = activityMap()
713
+ const controls = s.hive.doc.getMap('controls')
714
+ const parkedAfter = ACT.parkedAfterMs()
715
+ const now = Date.now()
716
+
717
+ const everHere = new Set(present)
718
+ for (const m of s.hive.doc.getArray('chat').toArray()) {
719
+ if (m && m.by) everHere.add(String(m.by).toLowerCase())
720
+ }
721
+
722
+ // HIV-P11b. Send-time inspection of recipient state:
723
+ // Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
724
+ function describeRecipientLiveness(n) {
725
+ const lc = n.toLowerCase()
726
+ const mem = memberMap.get(lc)
727
+ const isPresent = !!mem
728
+ const rec = act ? act.get(lc) : null
729
+ const c = controls.get(mem ? mem.name : n)
730
+ const isPaused = !!(c && c.state === 'paused')
731
+ const isPassive = !!(mem && mem.passive)
732
+ const d = ACT.describe(rec, {
733
+ present: isPresent,
734
+ paused: isPaused,
735
+ passive: isPassive,
736
+ now,
737
+ parkedAfter,
738
+ })
739
+ const displayName = mem ? mem.name : n
740
+
741
+ if (d.state === ACT.PARKED) {
742
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
743
+ const tool = d.lastTool ? ` (${d.lastTool})` : ''
744
+ return `⚠ @${displayName} — PARKED: connected, but NOT inside hive_wait and silent for ${age}${tool}. ` +
745
+ `It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
746
+ }
747
+ if (d.state === ACT.PAUSED) {
748
+ return `⚠ @${displayName} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
749
+ }
750
+ if (d.state === ACT.PASSIVE) {
751
+ return `⚠ @${displayName} — PASSIVE LISTENER. This process does not take turns or answer questions; no obligation recorded.`
752
+ }
753
+ if (d.state === ACT.GONE) {
754
+ if (everHere.has(lc)) {
755
+ return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
756
+ } else {
757
+ return `⚠ @${displayName} — NOT IN THIS ROOM. Nobody by that name is here, so nobody is going to answer. Call hive_members and re-address it now; this is the one failure mode that otherwise costs you a whole shift.`
758
+ }
759
+ }
760
+ // d.state is WORKING or WAITING
761
+ const busy = findBusy(busyMap, n)
762
+ if (busy) {
763
+ return describeRecipient(displayName, busy, true, now)
764
+ }
765
+ return ''
766
+ }
767
+
768
+ const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
769
+ const seenCandidates = new Set()
770
+ const uniqueCandidates = []
771
+ for (const n of candidateNames) {
772
+ const lc = n.toLowerCase()
773
+ if (!seenCandidates.has(lc)) {
774
+ seenCandidates.add(lc)
775
+ uniqueCandidates.push(n)
776
+ }
777
+ }
778
+ const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
779
+ const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
780
+ // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
781
+ // HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
782
+ // 1. Absent names: warned immediately, ZERO obligations opened.
783
+ // 2. Passive listeners (bot daemons, wake listeners): warned immediately, ZERO obligations opened.
784
+ // 3. Active participants: real debt opened, dischargeable ONLY by the asker.
785
+ // 4. @all broadcast: targets only active non-passive AI agents in the room.
786
+ let obStr = ''
787
+ if (args.needsReply) {
788
+ const obMap = s.hive.doc.getMap('obligations')
789
+ const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
790
+
791
+ let activeTargets = []
792
+ let awayTargets = []
793
+ let neverSeenNames = []
794
+ let passiveNames = []
795
+
796
+ if (mentionsAll(body)) {
797
+ activeTargets = members
798
+ .filter((m) => String(m.name).toLowerCase() !== s.name.toLowerCase() && !m.passive && m.kind === 'ai')
799
+ .map((m) => String(m.name))
800
+ } else {
801
+ for (const n of uniqueCandidates) {
802
+ const lc = n.toLowerCase()
803
+ const mem = memberMap.get(lc)
804
+ if (mem) {
805
+ if (mem.passive) {
806
+ passiveNames.push(mem.name || n)
807
+ } else {
808
+ activeTargets.push(mem.name || n)
809
+ }
810
+ } else {
811
+ if (everHere.has(lc)) {
812
+ awayTargets.push(n)
813
+ } else {
814
+ neverSeenNames.push(n)
815
+ }
816
+ }
817
+ }
818
+ }
819
+
820
+ const openedActive = activeTargets.map((n) => OB.open(obMap, {
821
+ from: s.name, to: n, text: body, dueMs,
822
+ absent: false,
823
+ neverSeen: false,
824
+ }))
825
+
826
+ const openedAway = awayTargets.map((n) => OB.open(obMap, {
827
+ from: s.name, to: n, text: body, dueMs,
828
+ absent: true,
829
+ neverSeen: false,
830
+ }))
831
+
832
+ const opened = [...openedActive, ...openedAway]
833
+
834
+ if (opened.length) {
835
+ obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
836
+ `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
837
+ `a reply does not close it either — you decide whether you were actually answered. If it ` +
838
+ `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
839
+ }
840
+ }
841
+ return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
842
+ }
843
+ case 'hive_busy': {
844
+ const s = requireSession()
845
+ const busyMap = s.hive.doc.getMap('busy')
846
+ if (args.clear) { busyMap.delete(s.name); return text('busy cleared — the room will expect normal replies from you again') }
847
+ const e = makeBusy(args.what, { ttl: args.minutes ? args.minutes * 60000 : undefined })
848
+ busyMap.set(s.name, e)
849
+ // Say what it does NOT do, because an agent that believes this silences its mail will
850
+ // stop calling hive_check and go deaf on purpose.
851
+ return text(`marked busy: ${e.what}\n\nThis posts no message and silences nothing — you still ` +
852
+ `receive mail and must still call hive_check between edits. It only tells anyone who ` +
853
+ `@mentions you, at the moment they send, that you are mid-task. It expires by itself in ` +
854
+ `${Math.round(e.ttl / 60000)} minutes, so a crash cannot leave the room believing you are ` +
855
+ `permanently busy. Call hive_busy with clear:true when you finish.`)
472
856
  }
473
857
  case 'hive_read_chat': {
474
858
  const me = requireSession().name
@@ -496,7 +880,22 @@ async function dispatch(req) {
496
880
  const got = args.peek ? box.peek(all) : box.take(all)
497
881
  if (!args.peek) persistCursor()
498
882
  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.')
883
+ // WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
884
+ // moment a message is handed over, so an agent can absorb a direct question, get busy,
885
+ // and never notice it left someone hanging -- which is the failure that made a previous
886
+ // room's PM give up on this channel and tail the chat file with a shell script instead.
887
+ // Derived from the log, so it needs no state and cannot drift.
888
+ const owed = unansweredFor(all, s.name)
889
+ const owedLine = owed.length
890
+ ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
891
+ `Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
892
+ `silence is the one thing they cannot act on.`
893
+ : ''
894
+ if (!got.unreadCount && !tasks.length) {
895
+ return text(owed.length
896
+ ? `✓ no new mail and no approved work.${owedLine}`
897
+ : '✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
898
+ }
500
899
  const parts = []
501
900
  if (got.priority === 'stop') {
502
901
  // HANDLING URGENT MAIL COUNTS AS LIVENESS.
@@ -529,7 +928,7 @@ async function dispatch(req) {
529
928
  const dig = ambientDigest(ambient)
530
929
  if (dig) parts.push(dig)
531
930
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
532
- return text(parts.join('\n\n'))
931
+ return text(parts.join('\n\n') + owedLine)
533
932
  }
534
933
  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
934
  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 +940,37 @@ async function dispatch(req) {
541
940
  }
542
941
  case 'hive_wait': {
543
942
  const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
544
- const r = await waitForWork(ms, { wakeOn: args.wakeOn })
943
+ // HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
944
+ // point", and the finally is what keeps that true. A wait that returns or throws while
945
+ // leaving the flag set describes an agent parked in a call it has already left -- and
946
+ // describes it as WAITING, the single most reassuring state in the taxonomy. stateOf()
947
+ // gates WAITING on awareness so a process that DIES here cannot fake it, but a live
948
+ // process that merely threw is still present, so the gate does not cover that and this
949
+ // does. The name is captured first because the session can be gone by the finally.
950
+ const waitName = requireSession().name
951
+ ACT.noteWaitStart(activityMap(), { name: waitName })
952
+ let r
953
+ try {
954
+ r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
955
+ } finally {
956
+ try { ACT.noteWaitEnd(activityMap(), { name: waitName }) } catch { }
957
+ }
545
958
  const globalState = requireSession().hive.getState()
546
- const stateStr = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
959
+ // THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
960
+ // sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
961
+ // it each time. It is now sent when the agent has not seen it, when it CHANGED (which
962
+ // always wins: a suppressed state change is a wrong answer, not a saving), or when enough
963
+ // time AND enough output have passed that it is probably no longer in context.
964
+ const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
965
+ const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
966
+ // The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
967
+ // compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
968
+ // ownership -- so the general rules in the system prompt do not cover them. A pointer is
969
+ // re-sent, never a copy: the file is on this agent's own disk, and a second summary here
970
+ // would be one more description to drift out of date.
971
+ const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
972
+ ? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
973
+ : ''
547
974
  // Connection state, reported ONLY when it is bad. An agent that cannot tell a
548
975
  // quiet room from a dead socket reaches for hive_join -- which calls stop() and
549
976
  // tears the session down, turning a blip that would have healed itself into a
@@ -558,6 +985,25 @@ async function dispatch(req) {
558
985
  : ''
559
986
 
560
987
  const me = requireSession().name
988
+ // THE KNOCK RIDES THE WAIT. hive_wait is the agent's idle state and a blocked wait costs
989
+ // zero tokens, so a reminder delivered here arrives on a call that was going to happen
990
+ // anyway. Agent Relay re-injects its boomerang on a broker timer, which spends a turn on
991
+ // every knock; this spends nothing. The reminder is deliberately NOT sent to a paused
992
+ // agent -- paused means stop, and nagging a stopped agent is just noise.
993
+ const obMap = (() => { try { return requireSession().hive.doc.getMap('obligations') } catch { return null } })()
994
+ let owedStr = ''
995
+ if (obMap) {
996
+ const due = OB.overdue(obMap, me)
997
+ if (due.length) {
998
+ // Only a DELIVERED knock moves the deadline. If merely NOTICING an overdue record
999
+ // rescheduled it, an agent that never calls hive_wait would have its deadline pushed
1000
+ // out forever by whoever happened to read the board -- exactly backwards.
1001
+ for (const o of due) OB.knock(obMap, o.id)
1002
+ owedStr = `\n[YOU OWE ${due.length} ANSWER(S)] These were asked of you and are still open. ` +
1003
+ `Answer in chat with @Name — you cannot close them yourself, the asker does that.\n` +
1004
+ due.map((o) => ' ' + OB.describe(o)).join('\n') + '\n'
1005
+ }
1006
+ }
561
1007
  // Direct address is separated from ambient traffic on every path. It used to be that
562
1008
  // only a PAUSED agent got this distinction, so a working agent could not tell "stop,
563
1009
  // wrong file" from two other agents talking to each other.
@@ -577,13 +1023,103 @@ async function dispatch(req) {
577
1023
  }
578
1024
  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
1025
  }
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}`)
1026
+ 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
1027
  const parts = []
582
1028
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
583
1029
  const mb = mentionBlock(r); if (mb) parts.push(mb)
584
1030
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
585
1031
  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.)'))
1032
+ return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
1033
+ }
1034
+ case 'hive_delivered': {
1035
+ const s2 = requireSession()
1036
+ const conn = (() => { try { return s2.hive.status() } catch { return null } })()
1037
+ const connected = !conn || !!conn.connected
1038
+ const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
1039
+ const rs = RC.receipts({
1040
+ chat: s2.hive.doc.getArray('chat').toArray(),
1041
+ cursors: s2.hive.doc.getMap('cursors'),
1042
+ me: s2.name,
1043
+ present: (n) => here.has(String(n).toLowerCase()),
1044
+ connected,
1045
+ limit: Math.min(Math.max(Number(args.limit) || 10, 1), 50),
1046
+ })
1047
+ 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.)')
1048
+ const states = rs.flatMap((r) => r.to)
1049
+ const read = RC.countDelivered(states)
1050
+ const unknown = states.filter((x) => x.state === RC.UNKNOWN).length
1051
+ // The headline deliberately reports READ over TOTAL and names UNKNOWN separately rather
1052
+ // than folding it in. Summing "not proven undelivered" into the good number is how a
1053
+ // receipt system starts reassuring instead of measuring.
1054
+ const head = `${read}/${states.length} confirmed READ` +
1055
+ (unknown ? ` — ${unknown} UNKNOWN (no read position published; that is an absence of evidence, not evidence of delivery)` : '')
1056
+ const worst = states.filter((x) => x.state !== RC.READ)
1057
+ return text(`${head}\n\n` + rs.map((r) => ' ' + RC.describe(r)).join('\n') +
1058
+ (worst.length ? '\n\n' + [...new Set(worst.map((x) => `${x.name}: ${x.why}`))].join('\n') : ''))
1059
+ }
1060
+ case 'hive_answered': {
1061
+ const s2 = requireSession()
1062
+ const obMap = s2.hive.doc.getMap('obligations')
1063
+ if (args.from) {
1064
+ const closed = OB.dischargeFrom(obMap, { by: s2.name, to: String(args.from) })
1065
+ if (!closed.length) return text(`Nothing open that you asked @${args.from}. hive_owed shows what is.`)
1066
+ return text(`Closed ${closed.length} question(s) you asked @${args.from}:\n` + closed.map((o) => ' ' + o.text).join('\n'))
1067
+ }
1068
+ if (!args.id) return err('hive_answered needs either id (one question) or from (everything you asked one agent). hive_owed lists both.')
1069
+ const r2 = OB.discharge(obMap, { id: String(args.id), by: s2.name })
1070
+ if (!r2.ok) return err(r2.reason)
1071
+ return text(r2.already ? `${args.id} was already closed.` : `Closed ${args.id}: "${r2.rec.text}"`)
1072
+ }
1073
+ case 'hive_owed': {
1074
+ const s2 = requireSession()
1075
+ const obMap = s2.hive.doc.getMap('obligations')
1076
+ const act = activityMap()
1077
+ const parkedAfter = ACT.parkedAfterMs()
1078
+ const members = s2.hive.members()
1079
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
1080
+ const controls = s2.hive.doc.getMap('controls')
1081
+ const now = Date.now()
1082
+
1083
+ // HIV-P11b. Dynamic reachability annotation for debts:
1084
+ // Distinguishes actionable pending debts from debts owed by PARKED, GONE, or PAUSED agents.
1085
+ function describeDebt(o) {
1086
+ const base = OB.describe(o, () => now)
1087
+ const lc = String(o.to || '').toLowerCase()
1088
+ const mem = memberMap.get(lc)
1089
+ const rec = act ? act.get(lc) : null
1090
+ const c = controls.get(mem ? mem.name : o.to)
1091
+ const d = ACT.describe(rec, {
1092
+ present: !!mem,
1093
+ paused: !!(c && c.state === 'paused'),
1094
+ passive: !!(mem && mem.passive),
1095
+ now,
1096
+ parkedAfter,
1097
+ })
1098
+ if (d.state === ACT.PARKED) {
1099
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
1100
+ return `${base} [UNREACHABLE — @${mem ? mem.name : o.to} is PARKED: silent for ${age}, will not wake on its own]`
1101
+ }
1102
+ if (d.state === ACT.GONE) {
1103
+ return `${base} [UNREACHABLE — @${o.to} is GONE: not connected]`
1104
+ }
1105
+ if (d.state === ACT.PAUSED) {
1106
+ return `${base} [PAUSED by human — do not reassign]`
1107
+ }
1108
+ return base
1109
+ }
1110
+
1111
+ if (args.all) {
1112
+ const openList = OB.openList(obMap, () => now)
1113
+ if (!openList.length) return text('Nothing outstanding in this room.')
1114
+ const staleCount = openList.filter(OB.isStale).length
1115
+ return text(`${openList.length} open, ${staleCount} stale:\n` + openList.map((o) => ' ' + describeDebt(o)).join('\n'))
1116
+ }
1117
+ const mine = OB.owedBy(obMap, s2.name, () => now), theirs = OB.owedTo(obMap, s2.name, () => now)
1118
+ if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
1119
+ const parts = []
1120
+ if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + describeDebt(o)).join('\n'))
1121
+ 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) => ' ' + describeDebt(o)).join('\n'))
1122
+ return text(parts.join('\n\n'))
587
1123
  }
588
1124
  case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
589
1125
  case 'hive_read_state': { return text(requireSession().hive.getState()) }
@@ -601,9 +1137,29 @@ async function dispatch(req) {
601
1137
  case 'hive_verified': {
602
1138
  const f = String(args.file || ''); if (!f) return err('need a file path')
603
1139
  const r = requireSession().hive.verified(f)
604
- if (!r.count) return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.`)
1140
+ // Attestation and gate verdicts are independent records, so an unattested path must still
1141
+ // report its gate. Returning early here would hide a FAILED check behind "nobody measured
1142
+ // the bytes", which is the opposite of what this tool is for.
1143
+ if (!r.count) {
1144
+ const g0 = requireSession().hive.gateVerdicts(f)
1145
+ const extra = Object.entries(g0).map(([n, v]) => v.forced
1146
+ ? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
1147
+ : `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
1148
+ return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
1149
+ }
605
1150
  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.'}`)
1151
+ // Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
1152
+ // WORKS, which is the actual question an agent is asking here — so the gate verdicts
1153
+ // belong in this answer. A file two peers agree on and whose owner's tests failed is the
1154
+ // exact thing somebody is about to build on.
1155
+ const g = requireSession().hive.gateVerdicts(f)
1156
+ const gLines = Object.entries(g).map(([n, v]) => v.forced
1157
+ ? ` [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ' and gave no reason'}`
1158
+ : ` ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}: ${v.cmd}`)
1159
+ const gate = gLines.length
1160
+ ? `\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.' : ''}`
1161
+ : '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
1162
+ 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
1163
  }
608
1164
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
609
1165
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
@@ -614,18 +1170,84 @@ async function dispatch(req) {
614
1170
  // always a mistake, and refusing here is far cheaper than refusing after the agent has
615
1171
  // built the content it intended to write.
616
1172
  enforceDcoLock({ path: region });
617
- const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
618
- if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
1173
+ // HIV-27: A CLAIM YOU CANNOT PUBLISH IS NOT A CLAIM. The claim is a path string in the
1174
+ // shared map; whether that path's BYTES sync is decided separately, per client. Nothing
1175
+ // compared them, so an agent could hold an exclusive claim on a file nobody else can
1176
+ // see, do the work, pass a gate running in its own folder where the file is real, and
1177
+ // hand back something that never existed for anyone else -- every signal green.
1178
+ // Refused HERE rather than at release: by release time the work is already built and
1179
+ // all we could do is tell the agent it was wasted.
1180
+ const unshared = requireSession().hive.whyNotShared(region)
1181
+ if (unshared) {
1182
+ return err(`refusing to claim ${region} — ${unshared}
1183
+
1184
+ 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.`)
1185
+ }
1186
+ // SETTLED, not optimistic. The synchronous claim answers from this replica alone and
1187
+ // tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
1188
+ // behind a lock it does not hold. This waits for the relay to serialise it.
1189
+ const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
1190
+ if (got) {
1191
+ // HIV-P18. "It's yours" was all this said, and for a file outside the working set that
1192
+ // was the entire defect: the agent reads its directory, sees nothing, concludes the file
1193
+ // does not exist, and CREATES it -- which is the blind recreate. Measured 21 Sep 2026:
1194
+ // claim returned true, the file doc was never opened, no bytes arrived, and the agent
1195
+ // was told it held something it could not see.
1196
+ //
1197
+ // sync.js now fetches on claim. This reports WHICH of three states the agent is in,
1198
+ // because they demand different next actions and an empty directory cannot tell them
1199
+ // apart. Awaited here rather than in claim(), whose contract is synchronous.
1200
+ let fetched = null
1201
+ try { fetched = await requireSession().hive.fetch(region) } catch { }
1202
+ const head = `claimed ${region} — it's yours.`
1203
+ if (fetched && fetched.notInRoom) {
1204
+ return text(`${head}
1205
+
1206
+ !! ${region} IS NOT IN THIS ROOM. No client has ever published it, so nothing will fetch it and it is NOT simply outside your working set. If you create it now you are creating a new file, not editing an existing one — which is correct only if that is what you intend. If you expected content here, the path is probably outside the room's sync scope: check that first, then ask whoever holds the file to write it into their synced folder.
1207
+
1208
+ Edit it, then call hive_release.`)
1209
+ }
1210
+ if (fetched && fetched.ok === false) {
1211
+ return text(`${head}
1212
+
1213
+ !! ${region} IS IN THE ROOM BUT ITS CONTENT HAS NOT ARRIVED. ${fetched.reason || ''}
1214
+ DO NOT create it from scratch — the room's copy exists and writing over it unions two whole files. Retry, or check hive_status (filesHeld of roomFiles).
1215
+
1216
+ Then call hive_release.`)
1217
+ }
1218
+ const note = fetched && fetched.ok && !fetched.alreadyLocal && !fetched.empty
1219
+ ? ` Its content was not in this folder and has been fetched (${fetched.bytes} bytes) — read it before editing.`
1220
+ : ''
1221
+ return text(`claimed ${region} — it's yours.${note} Edit it, then call hive_release.`)
1222
+ }
619
1223
  const c = requireSession().hive.senseClaim(region)
620
1224
  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
1225
  }
622
- case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
1226
+ case 'hive_release': {
1227
+ const region = String(args.region || '')
1228
+ if (!region) return err('need a region')
1229
+ // A gated release can REFUSE, and reporting "released" either way is the worst thing this
1230
+ // could do: the agent moves on believing it handed the file back while it still holds it.
1231
+ const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
1232
+ if (r && r.ok === false) return text(r.message)
1233
+ if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room`)
1234
+ return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}`)
1235
+ }
623
1236
  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
1237
  case 'hive_edit': {
625
1238
  const editPath = String(args.file || '')
626
1239
  // Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
627
1240
  // indistinguishable from no lease at all.
628
1241
  enforceDcoLock({ consumes: true, path: editPath })
1242
+ // HIV-27, same reasoning: this tool's entire promise is that the change reaches every
1243
+ // peer. On a path this client never opens a connection for, it would not -- and it
1244
+ // would still report success.
1245
+ const unshared = requireSession().hive.whyNotShared(editPath)
1246
+ if (unshared) {
1247
+ return err(`refusing to write ${editPath} through the medium — ${unshared}
1248
+
1249
+ Nothing was written. This tool exists to guarantee the change syncs to every peer; on this path it cannot.`)
1250
+ }
629
1251
  const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
630
1252
  if (r.error) return err(r.error)
631
1253
  return text(r.conflicted
@@ -649,7 +1271,40 @@ async function dispatch(req) {
649
1271
  const dropped = (r.coverage || []).filter((c) => !c.covered)
650
1272
  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.'}`)
651
1273
  }
652
- 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)') }
1274
+ case 'hive_members': {
1275
+ const now = Date.now()
1276
+ const s = requireSession()
1277
+ const act = activityMap()
1278
+ const controls = s.hive.doc.getMap('controls')
1279
+ const parkedAfter = ACT.parkedAfterMs()
1280
+ const present = s.hive.members()
1281
+ const presentKeys = new Set(present.map((m) => String(m.name).toLowerCase()))
1282
+
1283
+ const rows = present.map((m) => {
1284
+ const rec = act ? act.get(String(m.name).toLowerCase()) : null
1285
+ const c = controls.get(m.name)
1286
+ const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
1287
+ return ACT.line(`${m.name} (${m.kind})`, rec, {
1288
+ present: true, passive: !!m.passive,
1289
+ paused: !!(c && c.state === 'paused'),
1290
+ // HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
1291
+ // send it now will sit unread until a human prompts it` about the human.
1292
+ human: String(m.kind || '').toLowerCase() === 'human',
1293
+ now, parkedAfter,
1294
+ }) + editing
1295
+ })
1296
+
1297
+ // HIV-P11. THE ONES THAT ARE NOT HERE ANY MORE, which is the half a human coming back in
1298
+ // the morning actually needs. Awareness is ephemeral: an agent that died at 23:00 simply
1299
+ // vanishes from members(), so the old output could not distinguish "never joined" from
1300
+ // "worked here for three hours and stopped". The activity record is durable, so it can.
1301
+ const goneRows = ACT.all(act)
1302
+ .filter((r) => !presentKeys.has(r.key))
1303
+ .map((r) => ACT.line(r.name || r.key, r, { present: false, now, parkedAfter }))
1304
+
1305
+ const body = [...rows, ...(goneRows.length ? ['', '— not connected —', ...goneRows] : [])]
1306
+ return text(body.join('\n') || '(none)')
1307
+ }
653
1308
  case 'hive_status': {
654
1309
  const s = requireSession()
655
1310
  // Connection state belongs here too, not only in hive_wait: this is the tool an
@@ -657,17 +1312,36 @@ async function dispatch(req) {
657
1312
  // is almost always "you are still connected, do nothing".
658
1313
  let conn = null
659
1314
  try { conn = s.hive.status() } catch { }
1315
+ // `privacy` sits next to `connected` on purpose. These are the two questions an
1316
+ // agent asks here, and only one of them had an answer: "am I reaching anyone" was
1317
+ // reportable and "where is my source going" was not. A bare relay URL is not an
1318
+ // answer to the second -- the public relay and a company's own are the same shape.
1319
+ const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
660
1320
  return text({
661
1321
  room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
1322
+ privacy: pv.headline,
1323
+ ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1324
+ ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
662
1325
  unread: mailbox().peek(chatArray()).unreadCount,
663
1326
  connected: conn ? conn.connected : null,
664
1327
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
665
1328
  })
666
1329
  }
667
- case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1330
+ // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1331
+ // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1332
+ // and diluting it with agents that signed off correctly is how a signal stops being read.
1333
+ case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
668
1334
  default: return err(`unknown tool: ${name}`)
669
1335
  }
670
1336
  } catch (e) { return err(`error: ${e.message}`) }
1337
+ finally {
1338
+ // hive_join only: the session does not exist until the case has run, so this is the single
1339
+ // tool that cannot be stamped on the way in. Everything else is stamped above, before the
1340
+ // switch, so that an agent's own record reflects the call it is currently making.
1341
+ try {
1342
+ if (session && name === 'hive_join') ACT.noteAction(activityMap(), { name: session.name, tool: name })
1343
+ } catch { /* a liveness stamp must never be able to fail a tool call */ }
1344
+ }
671
1345
  }
672
1346
 
673
1347
  // MAIL PIGGYBACK — the delivery channel that cannot be ignored.
@@ -741,7 +1415,44 @@ function mailBanner() {
741
1415
  } catch { return '' }
742
1416
  }
743
1417
 
1418
+ // A MISSING REQUIRED ARGUMENT MUST BE REFUSED, NOT WRITTEN AS `undefined`.
1419
+ //
1420
+ // Nothing was validating `inputSchema.required`. The SDK does not enforce it, and every handler
1421
+ // read its arguments straight off the object, so a near-miss parameter name sailed through as
1422
+ // `undefined` and the tool reported success. Two real cases, both on 18 Sep 2026:
1423
+ // hive_say({ message }) -> posted an EMPTY message and answered "sent: "
1424
+ // hive_set_state({ value }) -> answered "Set global state [gate:cmd] to [undefined]", which
1425
+ // overwrote the room's gate command with the string "undefined"
1426
+ // and would have broken the handoff gate for every agent.
1427
+ // The failure mode is what makes it serious: the agent is told the action happened. It moves on,
1428
+ // and the room is left holding a corrupted value nobody wrote on purpose. Near-miss names are
1429
+ // the common case, so the refusal names the unknown argument that was supplied too.
1430
+ function missingRequired(req) {
1431
+ const name = req && req.params && req.params.name
1432
+ const tool = TOOLS.find((t) => t.name === name)
1433
+ const schema = tool && tool.inputSchema
1434
+ const required = schema && Array.isArray(schema.required) ? schema.required : []
1435
+ if (!required.length) return ''
1436
+ const args = (req.params && req.params.arguments) || {}
1437
+ // undefined/null only: '' and 0 and false are legitimate values a caller may mean.
1438
+ const missing = required.filter((k) => args[k] === undefined || args[k] === null)
1439
+ if (!missing.length) return ''
1440
+ const known = Object.keys(schema.properties || {})
1441
+ const unknown = Object.keys(args).filter((k) => !known.includes(k))
1442
+ const lines = [
1443
+ `REFUSED -- ${name} needs ${missing.map((m) => `\`${m}\``).join(', ')} and it was not supplied.`,
1444
+ `Nothing was written. This call did NOT happen.`,
1445
+ ]
1446
+ if (unknown.length) {
1447
+ lines.push(`You passed ${unknown.map((u) => `\`${u}\``).join(', ')}, which ${name} does not take -- most likely you meant ${missing.map((m) => `\`${m}\``).join(', ')}.`)
1448
+ }
1449
+ lines.push(`${name} accepts: ${known.map((k) => (required.includes(k) ? `${k} (required)` : k)).join(', ')}.`)
1450
+ return lines.join('\n')
1451
+ }
1452
+
744
1453
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
1454
+ const bad = missingRequired(req)
1455
+ if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
745
1456
  const res = await dispatch(req)
746
1457
  // Never let the banner break a tool: append only to a well-formed text result, and skip the two
747
1458
  // tools that already deliver mail themselves (appending would double-report the same messages).
@@ -771,6 +1482,10 @@ if (isEntryPoint) {
771
1482
  export const _test = {
772
1483
  waitForWork,
773
1484
  mailbox,
1485
+ // Exposed so the throttle's saving is asserted against the counter itself rather than inferred
1486
+ // from what happened not to appear in a tool result. Counting absences is how you prove a thing
1487
+ // is quiet, not how you prove it is working.
1488
+ throttleStats: () => throttle.stats(),
774
1489
  setSession(s) { session = s },
775
1490
  getSession() { return session },
776
1491
  callTool: (name, args = {}) =>