hivecode-mcp 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/hive-mcp.js CHANGED
@@ -36,15 +36,21 @@ import { startUpdateCheck, updateNotice } from './hive-update.js'
36
36
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
37
37
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
38
38
  import { startSync, parseLink } from './sync.js'
39
- import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
40
- import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
41
- import { INSTRUCTIONS } from './hive-instructions.js'
39
+ import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
40
+ import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
41
+ import { isSecuredRoom } from './token.js'
42
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
43
+ import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
42
44
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
43
45
  import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
44
46
  import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
45
47
  import * as OB from './hive-obligations.js'
46
48
  import * as RC from './hive-receipts.js'
49
+ import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
47
50
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
51
+ import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
52
+ import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
53
+ import { SESSION_FILE_NAME, readAgents, writeAgents, ownRecord, excludeFromGit } from './hive-session.js'
48
54
 
49
55
  const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
50
56
  let session = null // { hive, room, relay, dir, name, mailbox }
@@ -83,24 +89,49 @@ function resolveRoomRaw(link, dir) {
83
89
  return { relay: DEFAULT_RELAY, room: 'room-' + crypto.randomBytes(13).toString('base64url'), token: '', mode: 'HOSTED (new room)' }
84
90
  }
85
91
 
86
- // Session persistence: keyed to this process's cwd, so each agent runtime (which starts
87
- // its own MCP process from its own directory) resumes only its own identity. Lets the
88
- // session survive MCP host restarts, which otherwise silently drop the agent from the room.
89
- const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
90
- function persistSession({ dir, name, owner, cursor }) {
92
+ // Session persistence: in the folder this server was started in, so a restart of the MCP host
93
+ // rejoins as the same agent instead of silently dropping it from the room. That folder is often
94
+ // shared: two Claude Code sessions in one repo is the obvious way to run a branch room. So the file
95
+ // keeps one record per agent, each with this server's pid, and every read takes only this
96
+ // process's own record (hive-session.js says why it once held one agent, and what that broke).
97
+ const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
98
+ const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
99
+ let keptOutOfGit = false
100
+ function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
91
101
  try {
92
- const prev = readSessionFile() || {}
93
- const chatCursor = cursor === undefined ? prev.chatCursor : cursor
94
- fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor }))
102
+ const agents = readAgents(SESSION_FILE)
103
+ const prev = ownRecord(agents, { conv: CONV })
104
+ const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
105
+ // `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
106
+ // tokens -- the agent's shell does not hold this server's token, so that listener could not join.
107
+ // `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
108
+ // AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
109
+ const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
110
+ ...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
111
+ pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
112
+ for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
113
+ agents[name] = rec
114
+ writeAgents(SESSION_FILE, agents, rec)
115
+ // The launch folder is often the developer's own checkout: keep our files out of their `git status`.
116
+ if (!keptOutOfGit) { keptOutOfGit = true; excludeFromGit(path.dirname(SESSION_FILE)) }
95
117
  } catch { /* best-effort */ }
96
118
  }
97
119
  function readSessionFile() {
98
- try { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) } catch { return null }
120
+ try { return ownRecord(readAgents(SESSION_FILE), { conv: CONV }) } catch { return null }
121
+ }
122
+ /** Take this agent off the record (it left, or was refused), and only this agent. */
123
+ function forgetSession() {
124
+ try {
125
+ const agents = readAgents(SESSION_FILE)
126
+ const mine = ownRecord(agents, { conv: CONV })
127
+ for (const [n, s] of Object.entries(agents)) if (s.pid === process.pid || s === mine) delete agents[n]
128
+ writeAgents(SESSION_FILE, agents)
129
+ } catch { /* best-effort */ }
99
130
  }
100
131
  /** Save the read cursor so a host restart does not reopen the blind window it just closed. */
101
132
  function persistCursor() {
102
133
  if (!session || !session.mailbox) return
103
- persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
134
+ persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, authed: session.authed, kind: session.kind, passive: session.passive })
104
135
  // AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
105
136
  // what makes "did they actually read it" answerable by anybody other than me. The cursor only
106
137
  // advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
@@ -111,7 +142,7 @@ function persistCursor() {
111
142
  async function tryResumeSession() {
112
143
  try {
113
144
  if (!fs.existsSync(SESSION_FILE)) return
114
- const s = readSessionFile()
145
+ const s = readSessionFile() // only this process's own record: never an agent still running beside it
115
146
  if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
116
147
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
117
148
  }
@@ -158,7 +189,7 @@ function defaultJoinTarget(link, name) {
158
189
  return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
159
190
  }
160
191
 
161
- async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
192
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
162
193
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
163
194
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
164
195
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -170,19 +201,60 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
170
201
 
171
202
  This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
172
203
  }
204
+ // A PERSONAL ROOM IS ALWAYS A NEW ONE (8 Oct 2026, hive-room-key.js). Refused here, before the old
205
+ // session is torn down, for the same reason as the name check above.
206
+ if (personal && (link || branch)) {
207
+ return `private: true hosts a NEW personal room, so it cannot be combined with a ${link ? 'link' : 'branch'}.\n\nNothing was joined and your current session is untouched.`
208
+ }
209
+ if (personal && dir && roomIn(dir)) {
210
+ return `${path.resolve(dir)} already belongs to room ${roomIn(dir)}, and a folder holds one room. Pass a new dir, or leave out private to rejoin that room.\n\nNothing was joined and your current session is untouched.`
211
+ }
212
+ // A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
213
+ // this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
214
+ // session is torn down, for the same reason as the name check above.
215
+ // SINGLE IDENTITY PER MCP PROCESS (Q637). Some host apps (Antigravity, Cursor, Claude Desktop)
216
+ // run ONE shared MCP server process across all their agent windows and tabs. A second join with a
217
+ // different name while a session is live would silently usurp the first session, breaking chat
218
+ // identity and authorship attribution. Refused FIRST, before anything below touches the disk: the
219
+ // branch path marks the folder as not-a-checkout, and a refusal that has already written there is
220
+ // not "nothing was joined" (antigravity-3, 1 Nov).
221
+ if (session && session.name !== name) {
222
+ return `Q637 This Hivecode server is already in use by '${session.name}' in ${session.room} (${session.dir}). If you are a different agent, you are sharing one MCP server with it. Do not leave or rejoin; tell your person: add a second server entry (for example 'hivecode-2') in your app's MCP config and restart the app (docs/JOIN_WITH_AGENT.md). If you ARE '${session.name}' and mean to switch, join again with the name '${session.name}'.\n\nNothing was joined and your current session is untouched.`
223
+ }
224
+ let branchCtx = null
225
+ if (branch) {
226
+ const untouched = '\n\nNothing was joined and your current session is untouched.'
227
+ if (link) return `Pass branch or link, not both: a branch's room link comes from the repo's git config.${untouched}`
228
+ const bound = HB.resolveForJoin({ branch, repo: repo || process.cwd() })
229
+ if (!bound.ok) return bound.message + untouched
230
+ link = bound.link
231
+ if (!dir) dir = HB.roomFolder(link, name)
232
+ const mark = HB.markNotACheckout(path.resolve(dir))
233
+ if (!mark.ok) return `Q564 ${mark.why}${untouched}`
234
+ branchCtx = bound
235
+ }
173
236
  if (session) { try { session.hive.stop() } catch {} ; session = null }
174
237
  let hostedHere = false
238
+ // The new personal room exists only in memory until the privacy gate below has passed: a refused
239
+ // join must leave nothing behind, and that includes a key for a room nobody ever entered.
240
+ let fresh = null
241
+ if (personal) {
242
+ fresh = newPersonalRoom()
243
+ link = `${DEFAULT_RELAY}|${fresh.room}`
244
+ if (!dir) dir = path.join(HIVE_HOME, fresh.room, safeSegment(name))
245
+ hostedHere = true
246
+ }
175
247
  if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
176
248
  const ROOT = path.resolve(dir)
177
249
  fs.mkdirSync(ROOT, { recursive: true })
178
250
  const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
179
- const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
251
+ const mode = hostedHere ? (fresh ? 'HOSTED (new personal room)' : 'HOSTED (new room)') : resolvedMode
180
252
  // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
181
253
  // .hive.json write below and before startSync opens a socket. A refused join must leave
182
254
  // nothing behind -- no config pointing at the wrong relay for the next process to find,
183
255
  // and above all no published file. Checking after connecting would report a leak instead
184
256
  // of preventing one, which is the difference between a guard and a log line.
185
- const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
257
+ const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs, room })
186
258
  if (!priv.ok) {
187
259
  return `${priv.headline}
188
260
 
@@ -195,8 +267,13 @@ folder: ${ROOT}
195
267
  NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
196
268
  was not written. Fix the relay and call hive_join again.`
197
269
  }
270
+ if (fresh) saveRoomKey(fresh, { relay, host: name })
198
271
  // token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
199
- const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
272
+ let useToken = token || linkToken || process.env.HIVE_TOKEN || ''
273
+ // A personal room this machine hosts: the host's own token is minted from the key, and renewed
274
+ // before it runs out, so the host can never be locked out of its own room. Anyone else joining
275
+ // here keeps the token it came with.
276
+ if (!token && isSecuredRoom(room)) useToken = hostToken(room, name, useToken, { kind: kind || 'ai', owner })
200
277
  // persist the token too, so a re-join from .hive.json keeps the grant (the
201
278
  // invitee pastes the link once; subsequent joins just read the folder config).
202
279
  // pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
@@ -216,7 +293,7 @@ was not written. Fix the relay and call hive_join again.`
216
293
  // folder is precisely the situation the lock exists to prevent.
217
294
  let hive
218
295
  try {
219
- hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
296
+ hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive, app: clientName() })
220
297
  } catch (e) {
221
298
  if (e && e.code === 'EHIVELOCKED') {
222
299
  return text(
@@ -226,8 +303,8 @@ was not written. Fix the relay and call hive_join again.`
226
303
  }
227
304
  throw e
228
305
  }
229
- session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
230
- persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
306
+ session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, authed: !!useToken, mailbox: null }
307
+ persistSession({ dir, name, owner, authed: !!useToken, kind: kind || 'ai', passive: !!passive }) // survive MCP host restarts (see tryResumeSession)
231
308
  // Wait for the first sync so members/board/rules are ready.
232
309
  //
233
310
  // REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
@@ -259,7 +336,7 @@ was not written. Fix the relay and call hive_join again.`
259
336
  // authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
260
337
  // Only when nothing refused us with a code do we query /health to infer whether it was capacity.
261
338
  if (refusedCode) {
262
- const explanation = RH.explainStatus(refusedCode, relay)
339
+ const explanation = RH.explainStatus(refusedCode, relay, { hasToken: !!useToken })
263
340
  return [
264
341
  `NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
265
342
  `room: ${room}`,
@@ -296,6 +373,29 @@ was not written. Fix the relay and call hive_join again.`
296
373
  'it reports `connected` and `filesSynced`, and both are authoritative.',
297
374
  ].join('\n')
298
375
  }
376
+ // ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
377
+ // a name already here is the same identity to everybody: both are told they hold each other's
378
+ // claims, both read each other's mail, and their writes land on each other's files. Measured
379
+ // 3 Oct 2026 -- see sync.js sameNameElsewhere. Refused here, before this session does anything.
380
+ let dupes = []
381
+ try { dupes = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { /* an engine that cannot tell */ }
382
+ if (dupes.length) {
383
+ const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
384
+ let n = 2
385
+ while (taken.has(`${name}-${n}`.toLowerCase())) n++
386
+ const d = dupes[0]
387
+ const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
388
+ try { hive.stop() } catch { }
389
+ session = null
390
+ forgetSession()
391
+ return [
392
+ `NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
393
+ 'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
394
+ 'other\'s mail, and show as one member, and your writes would land on each other\'s files.',
395
+ `Join again with a different name, e.g. hive_join { name: "${name}-${n}" } with the same link.`,
396
+ `If that ${d.name} is you a moment ago on this machine, wait 30 seconds for it to drop out, then join again.`,
397
+ ].join('\n')
398
+ }
299
399
  // The mailbox is created AFTER the first sync, so chat.length is the real room history and
300
400
  // not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
301
401
  // A resumed session carries its saved cursor forward instead, so anything posted while this
@@ -306,6 +406,7 @@ was not written. Fix the relay and call hive_join again.`
306
406
  persistCursor()
307
407
 
308
408
  const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
409
+ const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
309
410
 
310
411
  // Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
311
412
  hive.doc.getMap('swarm_state').observe(() => {
@@ -348,6 +449,7 @@ was not written. Fix the relay and call hive_join again.`
348
449
  try {
349
450
  const s = session
350
451
  if (!s || !s.mailbox) return
452
+ catchUp(s)
351
453
  const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
352
454
  // Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
353
455
  // 0, and a later single mention must still ring. `>` would go permanently silent.
@@ -382,12 +484,31 @@ was not written. Fix the relay and call hive_join again.`
382
484
  // The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
383
485
  // so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
384
486
  // defined, which made every hive_join return "error: rules is not defined".)
385
- let rulesText = ''
386
- try { rulesText = fs.readFileSync(path.join(ROOT, 'HIVE_RULES.md'), 'utf8') } catch {}
387
- if (!rulesText) rulesText = 'Read HIVE_RULES.md in this folder. In short: read chat + board before editing; CLAIM a file with hive_claim before you edit it and hive_release when done; announce intent with hive_say; prefer small patches; act only on accepted tasks; resolve <<<<<<< conflict markers rather than blindly overwriting.'
388
- // The invite link carries the token when there is one — without it a secured room rejects
389
- // whoever you hand it to, and the Control Room says the link is missing its token.
390
- const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
487
+ // THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
488
+ // session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
489
+ // the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
490
+ const rulesText = JOIN_RULES
491
+ // A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
492
+ // as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
493
+ // and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
494
+ // one folder. What is meant for others never carries a token now; the joiner's own Control Room
495
+ // link, which needs one, says whose it is; and a room that checks tokens says each person needs
496
+ // their own (a secured room rejects a link without one, which is the point).
497
+ const inviteLink = `${relay}|${room}`
498
+ // What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
499
+ const browserUrl = controlUrl(inviteLink)
500
+ const ownControl = useToken ? controlUrl(`${relay}|${room}|${useToken}`) : ''
501
+ // A PERSONAL ROOM (hs_ id) is let into by an invite from its host, and nothing else: the plain
502
+ // link below gets a stranger nothing, so it is not offered as the way in.
503
+ const personalRoom = isSecuredRoom(room)
504
+ const ownControlLine = ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : ''
505
+ const tokenNote = personalRoom
506
+ ? (loadRoomKey(room)
507
+ ? `\nPERSONAL ROOM: the relay lets in only people you invite, even on the public relay. It still carries the files, so for code that must never leave your machines, run your own relay. Invite one person at a time (each link works only for them): npx -y hivecode-mcp@latest invite <name> --dir "${ROOT}" (add --reader, or --paths src,docs). Take one back: npx -y hivecode-mcp@latest revoke <name> --dir "${ROOT}"`
508
+ : `\nPERSONAL ROOM: only people its host invites can join. To bring someone in, ask the host for an invite of their own; never pass on yours.`) + ownControlLine
509
+ : useToken
510
+ ? `\nthis room checks tokens: each person joins with a token of their own. Mint one per person with npx -y -p hivecode-relay hivecode-token --name <them> --room "${room}" --role agent (add --paths to limit them) and send it to them directly. Never pass on yours: whoever holds it joins as you.` + ownControlLine
511
+ : ''
391
512
  // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
392
513
  // who has not run a relay -- the public one and a company's own look identical. The
393
514
  // bracketed verdict is what makes "did my source just leave the building?" answerable
@@ -399,9 +520,47 @@ was not written. Fix the relay and call hive_join again.`
399
520
  ? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
400
521
  `It now uses ${relay}, and .hive.json has been updated, so this happens once.`
401
522
  : ''
402
- return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${movedNote}${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.${updateNotice() ? '\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
523
+ // What the room cannot carry (binary, over 1 MB, always-ignored) comes from git, once the room's
524
+ // own files are known. Only those: a text file missing here was deleted in the room.
525
+ let branchNote = ''
526
+ if (branchCtx) {
527
+ let filled = 0
528
+ let fillErr = ''
529
+ if (branchCtx.commit) { try { filled = HB.fillFromGit({ root: branchCtx.root, commit: branchCtx.commit, dir: ROOT }) } catch (e) { fillErr = e.message } }
530
+ branchNote = `BRANCH ROOM: this room is git branch "${branchCtx.branch}" of ${branchCtx.root}.\n` +
531
+ `Edit files in YOUR folder (${ROOT}), never in ${branchCtx.root}. It is deliberately not a git checkout: git refuses to run there, so no git command can rewrite the room's files. Do not commit; the work lands on "${branchCtx.branch}" as one commit per agent when the developer runs: npx -y hivecode-mcp@latest pr ${branchCtx.branch}` +
532
+ (filled ? `\nFilled from git: ${filled} file(s) the room does not carry (binary, over 1 MB, or never synced).` : '') +
533
+ (fillErr ? `\nCould not fill non-text files from git (${fillErr}); binary files may be missing here.` : '') + '\n\n'
534
+ }
535
+ // THE INVITE, READY TO FORWARD (onboarding audit, 2 Oct 2026). The host used to assemble it by
536
+ // hand: the install, the reload step, the sentence, the link. setup given a link prints that
537
+ // person's reload steps and their exact sentence, so one message covers all of it. Host only:
538
+ // this reply stays in the transcript, and a joiner already has the link it came in by.
539
+ // One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
540
+ // apps as a wall of links).
541
+ const people = browserUrl ? `\n No AI agent? Open ${browserUrl}` : ''
542
+ const invite = personalRoom ? ''
543
+ : /^HOSTED/.test(mode)
544
+ ? `\nto invite someone, forward this as is:\n Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:\n npx -y hivecode-mcp@latest setup "${inviteLink}"${people}`
545
+ : (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
546
+ // No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
547
+ // (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
548
+ return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\n${personalRoom ? 'room link (lets nobody in without an invite)' : 'invite link'}: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
549
+ }
550
+
551
+ // A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
552
+ // nobody, and agents are told to skip messages that name nobody; the joiner can act, so the join
553
+ // reply names every AI member whose reported build differs. Humans carry no engine build, and a
554
+ // client that reports none is the room's warning to give (checkBuilds speaks for old engines).
555
+ function mixedLine(members, me, label) {
556
+ const diff = (members || []).filter((m) => m && m.name && m.name !== me && String(m.kind || '').toLowerCase() !== 'human' && m.hv && m.hv !== label)
557
+ if (!diff.length) return ''
558
+ return `\n⚠ MIXED BUILDS: ${diff.map((m) => `${m.name} runs ${m.hv}`).join(', ')}; you run ${label}. Two builds in one room can lose file content: tell your person, so everyone runs the same build.`
403
559
  }
404
560
 
561
+ // Connected-or-not without status()'s file counts, which stat every file in the room. Falls back
562
+ // to status() for a hive that predates connection() (a test double, an older engine).
563
+ const connectionOf = (hive) => (typeof hive.connection === 'function' ? hive.connection() : hive.status())
405
564
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
406
565
  /** The read cursor for this participant. Lazily built if a session predates it. */
407
566
  function mailbox() {
@@ -411,8 +570,24 @@ function mailbox() {
411
570
  // rather than two that disagree.
412
571
  try { throttle.sent('rules', RULES_NUDGE) } catch { }
413
572
  if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
573
+ catchUp(s)
414
574
  return s.mailbox
415
575
  }
576
+ // THE LISTENER READS MAIL TOO (3 Oct 2026). An agent on Claude Code or Antigravity is woken by
577
+ // hivecode-await, a separate process with a cursor of its own, so every message it delivered was
578
+ // still unread here: the next tool call announced "UNREAD MAIL: N ... Handle before continuing"
579
+ // about mail the agent had already acted on (19 of them in one session), and an agent that obeys
580
+ // redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
581
+ // whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
582
+ // a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
583
+ function catchUp(s) {
584
+ if (!s || !s.mailbox) return
585
+ try {
586
+ const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
587
+ const at = (c && Number(c.at)) || 0
588
+ if (at > s.mailbox.cursor && at <= s.hive.doc.getArray('chat').length) s.mailbox.seek(at)
589
+ } catch { /* a room that predates receipts */ }
590
+ }
416
591
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
417
592
 
418
593
  // HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
@@ -539,61 +714,116 @@ function waitForWork(timeoutMs, opts = {}) {
539
714
  })
540
715
  }
541
716
 
717
+ // SAID ONCE, THEN SHORT (27 Sep 2026). An explanation is worth its tokens the first time an agent
718
+ // meets a rule in a session and nothing after that: results stay in the transcript, so a paragraph
719
+ // repeated on every call is paid again on every later turn. The warning itself always stays.
720
+ const explained = new Set()
721
+ const firstTime = (key) => (explained.has(key) ? false : (explained.add(key), true))
722
+
723
+ // A ROOM CHANGE THAT COULD NOT LAND HERE (3 Oct 2026). sync.js tells the room when a file in this
724
+ // folder is read-only or locked and so stays behind the room's copy, but that line goes out in this
725
+ // agent's own name, and an agent is never handed its own lines: the one participant working from
726
+ // the stale copy was the one nobody told. Said once per file, on a call the agent was making anyway.
727
+ function unwritableNotice(s) {
728
+ try {
729
+ const fresh = (s.hive.unwritable ? s.hive.unwritable() : []).filter((u) => firstTime('unwritable:' + u.path))
730
+ if (!fresh.length) return ''
731
+ const shown = fresh.slice(0, 5)
732
+ return `\n\n⚠ ${fresh.length} file(s) in your folder could not be written (read-only, or locked by another program), so your copy is OLDER than the room's:\n` +
733
+ shown.map((u) => ` - ${u.path} (${u.code})`).join('\n') +
734
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
735
+ `\nDo not work from that copy. Make it writable or close what holds it, or tell your person: it is written as soon as it can be, and anything you changed in it is merged, not lost.`
736
+ } catch { return '' }
737
+ }
738
+
739
+ // TWO ROOM FILES, ONE FILE ON THIS DISK (3 Oct 2026; sync.js checkCaseCollision). Case.js and case.js
740
+ // can both be in the room, but a Windows or Mac disk holds only one of them, so the other is not in
741
+ // this folder. The room is told in this agent's own name; this is where the agent is told.
742
+ function collidedNotice(s) {
743
+ try {
744
+ const fresh = (s.hive.collided ? s.hive.collided() : []).filter((c) => firstTime('collided:' + c.path))
745
+ if (!fresh.length) return ''
746
+ const shown = fresh.slice(0, 5)
747
+ return `\n\n⚠ ${fresh.length} room file(s) differ from a file in your folder only in upper/lower case. This disk cannot hold both, so they are NOT in your folder:\n` +
748
+ shown.map((c) => ` - ${c.path} (your folder has ${c.with})`).join('\n') +
749
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
750
+ `\nThey are separate files in the room: editing yours never changes the other. To work on one, ask whoever added it to rename it so the two names differ by more than case.`
751
+ } catch { return '' }
752
+ }
753
+
754
+ // A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
755
+ // is usually the one that needs telling, and the room line about it is in that agent's own name.
756
+ function withheldNotice(s) {
757
+ try {
758
+ const fresh = (s.hive.withheld ? s.hive.withheld() : []).filter((w) => firstTime('withheld:' + w.path))
759
+ if (!fresh.length) return ''
760
+ const shown = fresh.slice(0, 5)
761
+ return `\n\n⛔ ${fresh.length} file(s) in your folder were NOT shared, because each contains a credential (a key or token) the room does not have:\n` +
762
+ shown.map((w) => ` - ${w.path}: ${/^(?:[aeiou]|npm\b)/i.test(w.kind) ? 'an' : 'a'} ${w.kind} on line ${w.line}`).join('\n') +
763
+ (fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
764
+ `\nNobody else has your changes to these files. Read the key from the environment instead (put it in .env, which is never shared); each file is shared on its next save without the key.`
765
+ } catch { return '' }
766
+ }
767
+
542
768
  const TOOLS = [
543
- { 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.',
544
- inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync. Leave it out and Hivecode uses ~/hivecode/<room>/<your name>, a folder of your own. Never share a folder with another participant.' }, 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' } } } },
545
- { 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.',
546
- inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
547
- { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
769
+ { name: 'hive_join', description: 'Join a Hivecode room, or host a new one, for a folder. Uses the join link if given, else <dir>/.hive.json, else hosts. Returns the room details and the rules you must follow.',
770
+ inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync; default ~/hivecode/<room>/<name>. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human who approves tasks aimed at you' }, token: { type: 'string', description: 'token for a secured room (default $HIVE_TOKEN)' }, kind: { type: 'string', description: '"ai" (default) or "human"' }, passive: { type: 'boolean', description: 'true = a listener or bot that never takes turns or answers' }, branch: { type: 'string', description: 'instead of link: the room bound to this git branch (npx -y hivecode-mcp@latest branch <name>)' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' }, private: { type: 'boolean', description: 'host a NEW room only people you invite can join' } } } },
771
+ { name: 'hive_assign', description: 'Give a task to a participant by name. A task for an AI with an owner stays PENDING until that owner approves.',
772
+ inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
773
+ { name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
774
+ inputSchema: { type: 'object', properties: {} } },
775
+ { name: 'hive_wait', description: 'Your idle state. Blocks until there is approved work, mail for you, or a state flag you named reaches its value, then returns all of it; returns at once if any is already true. Loop: hive_wait -> work -> hive_complete -> hive_wait.',
776
+ inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, 110 in Claude Code and Antigravity; max 300, kept under your app\'s own tool deadline)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: '"any" (default), or "mention" = wake only when @mentioned or assigned; other mail still arrives on the next wake' }, until: { type: 'object', description: 'wake when a project state key reaches a value: {key, is}, {key, in: [...]}, {key, not} (unset counts as not), or {key} = set at all. The result carries stateReached.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
777
+ { name: 'hive_check', description: 'Non-blocking check for use mid-task: unread mail for you and approved work, returned at once. Call it between edits; if it reports priority "stop", handle that first.',
778
+ inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
779
+ { name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
780
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
781
+ { name: 'hive_deny', description: 'Deny a pending task for an AI you own; it will not act on it.',
782
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
783
+ { name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
784
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
785
+ { name: 'hive_say', description: 'Post to the room chat. Put @Name on anything meant for someone: a message naming nobody is ambient and gets skipped. Announce a file before you edit it.',
786
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = [!]: the recipient handles it before continuing' }, needsReply: { type: 'boolean', description: 'true = a question: tracked until YOU close it with hive_answered; you are told at once if someone you named is not here' }, replyWithinMinutes: { type: 'number', description: 'remind you after this many minutes (default 10)' } }, required: ['text'] } },
787
+ { name: 'hive_delivered', description: 'Did recipients actually READ what you said? Per recipient: READ; UNREAD (here, behind); UNKNOWN (no read position, e.g. a human client, so silence means nothing); UNREACHABLE (left, or you are offline). Check before concluding someone is ignoring you.',
788
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
789
+ { name: 'hive_answered', description: 'Close a question you asked with needsReply once you have what you needed. Only the asker can close it; a reply alone does not.',
790
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id' }, from: { type: 'string', description: 'close every question you asked this agent' } } } },
791
+ { name: 'hive_owed', description: 'Open questions: those aimed at you (answer them) and those you asked (close them with hive_answered). STALE means reminded three times. Read it when the room goes quiet.',
792
+ inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room' } } } },
793
+ { name: 'hive_busy', description: 'Mark yourself mid-task: whoever @mentions you is told to expect a delay. Posts nothing, silences nothing, expires by itself. Clear it when you finish.',
794
+ inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are doing, in a few words' }, minutes: { type: 'number', description: 'how long (default 10)' }, clear: { type: 'boolean', description: 'true = free again' } } } },
795
+ { name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
796
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' }, mentioningMe: { type: 'boolean', description: 'true = only messages that @mention you, across all history' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' }, full: { type: 'boolean', description: 'true = long messages whole (other people\'s are cut at 280 chars by default)' } } } },
797
+ { name: 'hive_read_board', description: 'Recent whole-file rewrites: who, which file, which symbols. If a file you plan to edit was just rewritten, re-read it first.',
798
+ inputSchema: { type: 'object', properties: {} } },
799
+ { name: 'hive_claim', description: 'Claim a file (or region) before you edit it. Returns whether you got it; if not, someone holds it, so work on something else. Claims expire; hive_release when done.',
800
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
801
+ { name: 'hive_release', description: 'Hand a file back. If the room set a gate, it runs in your folder first, and a failing check REFUSES the release: the file stays yours and retrying will not help. Fix what it reported, or force with a reason to abandon the work.',
802
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded against you and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
803
+ { name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
548
804
  inputSchema: { type: 'object', properties: {} } },
549
- { 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.',
550
- 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'] } } } },
551
- { 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.',
552
- inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
553
- { name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
554
- inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
555
- { name: 'hive_deny', description: 'Deny a pending task (you must be the owner of the target AI).',
556
- inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
557
- { name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
558
- inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
559
- { 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.',
560
- 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'] } },
561
- { 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.',
562
- inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
563
- { 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.',
564
- 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' } } } },
565
- { 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.',
566
- inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
567
- { 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.',
568
- 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' } } } },
569
- { 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.',
570
- 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)' } } } },
571
- { 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.',
805
+ { name: 'hive_edit', description: 'Write a file through the medium (preferred over editing on disk): your intent and the exact change are signed into the provenance ledger as it lands. Claim the file first.',
806
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'why you are making the change (signed into provenance)' } }, required: ['file', 'content'] } },
807
+ { name: 'hive_fork', description: 'Read a silent fork on a file: the common base plus every divergent version with its author and signed intent. Reconcile them, then submit with hive_resolve.',
808
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path, as announced in chat' } }, required: ['file'] } },
809
+ { name: 'hive_resolve', description: 'Resolve a fork with the full reconciled file, serving every side\'s intent. It must parse with no dangling references, or it is refused and nothing changes.',
810
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved content, no conflict markers' } }, required: ['file', 'code'] } },
811
+ { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).',
572
812
  inputSchema: { type: 'object', properties: {} } },
573
- { 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).',
574
- 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'] } },
575
- { 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).',
576
- inputSchema: { type: 'object', properties: {
577
- region: { type: 'string', description: 'the file path (or region id) to release' },
578
- 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.' },
579
- 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.' },
580
- }, required: ['region'] } },
581
- { 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.',
813
+ { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).',
582
814
  inputSchema: { type: 'object', properties: {} } },
583
- { 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.',
584
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative file path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'WHY you are making this change (signed into provenance)' } }, required: ['file', 'content'] } },
585
- { name: 'hive_fork', description: 'Read an active SILENT FORK on a file as a resolvable object: the common base plus EVERY divergent version with its author and cryptographically signed intent (the WHY). Call this when chat announces a fork, then reconcile both intents and submit via hive_resolve.',
586
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path (as announced in chat)' } }, required: ['file'] } },
587
- { name: 'hive_resolve', description: 'Resolve an active fork by submitting the FULL reconciled file content — code that serves EVERY side\'s signed intent (read them with hive_fork), not just one winner. The medium VALIDATES your proposal with ICR (must parse, no dangling references) before it lands; a broken proposal is refused and nothing changes. On success the resolution is signed by you, synced to every peer, and announced.',
588
- inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved file content (no <<<<<<< markers)' } }, required: ['file', 'code'] } },
589
- { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
590
- { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
591
- { name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
592
- { name: 'hive_set_state', description: 'PM ONLY: Set a global project state flag (e.g. key="contract", val="READY"). This physically unblocks/blocks other agents. WARNING: ONLY use short enum values. Do NOT use this to store paragraphs of text or code, as it will bloat the context window. Use files for text.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, val: { type: 'string' } }, required: ['key', 'val'] } },
593
- { name: 'hive_read_state', description: 'Read the global project state flags.', inputSchema: { type: 'object', properties: {} } },
594
- { name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key. Beyond `enum` you can make it a real state MACHINE: `transitions` ({"READY":["IN_PROGRESS"]}) says which moves are legal at all, and `requires` ({"LIVE":{"suppression_ui":"APPROVED"}}) says what must already be true to arrive at a value — which is how you make a release gate hold by construction instead of by everyone remembering it. Example: rules={"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"],"APPROVED":[]},"requires":{"APPROVED":{"tests":"GREEN"}}}', inputSchema: { type: 'object', properties: { key: { type: 'string' }, rules: { type: 'object' } }, required: ['key', 'rules'] } },
595
- { name: 'hive_attest', description: 'Record what YOUR folder actually holds for a file, so the room can tell whether peers agree. Measure it yourself (bytes and lines) and report. If another peer has attested a DIFFERENT size, everyone is warned immediately — that disagreement is the point. Use it after publishing a file, and when asked to confirm somebody else\'s publish. Two matching attestations from different peers is the only real proof a write landed; your own folder shows your own write whether or not it propagated.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in YOUR folder' }, lines: { type: 'number', description: 'line count in YOUR folder' } }, required: ['file', 'bytes'] } },
596
- { name: 'hive_verified', description: 'Has a file been independently confirmed? Returns every peer\'s attested size, whether they DISAGREE, and whether quorum is reached (2+ peers, same bytes). Check this before trusting that a publish landed, and before building on a file somebody else wrote.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
815
+ { name: 'hive_leave', description: 'Leave the room and stop syncing.',
816
+ inputSchema: { type: 'object', properties: {} } },
817
+ { name: 'hive_set_state', description: 'PM only: set a global project state flag (e.g. key="contract", val="READY") that blocks or unblocks agents. Short enum values only; text belongs in files.',
818
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
819
+ { name: 'hive_read_state', description: 'Read the global project state flags.',
820
+ inputSchema: { type: 'object', properties: {} } },
821
+ { name: 'hive_set_schema', description: 'PM only: validate a state key with JSON rules: enum, transitions (legal moves) and requires (what must already hold to arrive). Example: {"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"]},"requires":{"APPROVED":{"tests":"GREEN"}}}',
822
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
823
+ { name: 'hive_attest', description: 'Record what YOUR folder holds for a file (bytes, lines) so peers can compare; a different size from another peer warns everyone. Two matching attestations from different peers are the only proof a write landed.',
824
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in your folder' }, lines: { type: 'number', description: 'line count in your folder' } }, required: ['file', 'bytes'] } },
825
+ { name: 'hive_verified', description: 'Has a file been independently confirmed? Every peer\'s attested size, whether they disagree, and whether quorum (2+ peers, same bytes) is reached.',
826
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' } }, required: ['file'] } },
597
827
  ]
598
828
 
599
829
  // The state machine's enforcement point. Three separate gates, and each one exists because a rule
@@ -643,7 +873,7 @@ function enforceDcoLock(opts = {}) {
643
873
  POISONED: `[Path Poisoned] ${opts.path} must NOT be written. Publish to a NEW path instead. (Legacy flag — prefer DELETED_UNWRITABLE / CORRUPT / DIVERGED, which say WHY.)`,
644
874
  DELETED_UNWRITABLE: `[Path Unwritable] ${opts.path} had a delete propagate. A write here vanishes or silently resurrects the deleted content, and two folders end up permanently disagreeing with no marker. Never write it and never delete it again — publish to a NEW path.`,
645
875
  CORRUPT: `[Path Corrupt] ${opts.path} is unusable in every folder that has measured it. Do not write it and do not read it as reference — build the replacement out-of-tree and publish it to a NEW path.`,
646
- DIVERGED: `[Path Diverged] peers hold DIFFERENT bytes for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
876
+ DIVERGED: `[Path Diverged] peers hold DIFFERENT content for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
647
877
  FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
648
878
  }
649
879
  if (ps && stop[ps]) throw new Error(stop[ps])
@@ -699,14 +929,15 @@ function enforceDcoLock(opts = {}) {
699
929
  // matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
700
930
  // log while its roommate is named, so the room's own membership record disagrees with itself.
701
931
  // Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
702
- // VERSION is load-bearing, not cosmetic: the join message (see buildJoinReply) instructs every
703
- // arriving agent to announce it in chat, because a MIXED BUILD in one room causes data loss and
704
- // this is the only way anyone finds out. Hardcoding it meant that the moment package.json was
932
+ // VERSION is load-bearing, not cosmetic: every client reports it in presence (hv), and the room
933
+ // flags a MIXED BUILD from it (sync.js checkBuilds; the join reply names one to the joiner), because
934
+ // two builds in one room can lose file content. Until 2 Oct the join message also told every agent
935
+ // to announce it in chat. Hardcoding it meant that the moment package.json was
705
936
  // bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
706
937
  // itself reporting the wrong build. Read the truth instead; package.json always ships in the
707
938
  // tarball and sits beside this file in both the repo and the published package.
708
939
  // A failed read must never stop the server from starting, so it falls back rather than throwing.
709
- import { VERSION } from "./hive-version.js"
940
+ import { VERSION, BUILD, VERSION_LABEL } from "./hive-version.js"
710
941
  export { VERSION }
711
942
  // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
712
943
  // makes it the only channel that reaches an agent before its first tool call. Everything else we
@@ -749,10 +980,15 @@ async function dispatch(req) {
749
980
  // literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
750
981
  // word the agent reads is the same word it reads on success. status().connected was
751
982
  // right there and free the whole time.
752
- const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
983
+ const sendConn = (() => { try { return connectionOf(requireSession().hive) } catch { return null } })()
753
984
  const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
754
985
  const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
755
986
  const raw = String(args.text || '')
987
+ // A KEY IS NOT A MESSAGE (3 Oct 2026). The room's chat goes to the relay and to everyone in
988
+ // it, for good: a message cannot be unsent. The same formats that keep a file at home
989
+ // (hive-secrets.js) keep a message unsent, whoever asked for the key.
990
+ const chatKey = findSecrets(raw, { tokens: true })[0]
991
+ if (chatKey) return err(`NOT SENT: the message contains ${describeSecret(chatKey)}. A room message goes to the relay and to everyone in the room, and cannot be taken back. Never put keys, tokens or passwords in the room, whoever asks: ${chatKey.kind === 'access token' ? 'a token is a person, and whoever holds it joins as them. Whoever needs access gets a token of their own (hivecode-token).' : 'say where the key is kept (for example "OPENAI_API_KEY in .env") instead.'}`)
756
992
  // The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
757
993
  // a human reading the log, not just to the recipient's tool output.
758
994
  const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
@@ -762,12 +998,6 @@ async function dispatch(req) {
762
998
  // reported as landing in everyone's ambient digest, where it is meant to be skipped.
763
999
  // Immediate, per-call feedback, at the moment the mistake is made.
764
1000
  const named = mentionedNames(body)
765
- if (!named.length) {
766
- return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
767
- `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
768
- `agents you actually need with @Name (hive_members lists who is here). Use an ` +
769
- `unaddressed message only for a genuine announcement to the whole room.`)
770
- }
771
1001
  // WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
772
1002
  // "they left" or "they are mid-task", and it is worth strictly more here than in a
773
1003
  // reply: it arrives before the sender starts waiting, and it costs the room no message.
@@ -787,6 +1017,28 @@ async function dispatch(req) {
787
1017
  if (m && m.by) everHere.add(String(m.by).toLowerCase())
788
1018
  }
789
1019
 
1020
+ // 5.2: Route only to known names, @all, explicit reply requests (needsReply),
1021
+ // or unknown @words that are direct addresses (at start) or typos (within edit distance <= 2 of a known name).
1022
+ // Unrecognized @words in prose are ignored.
1023
+ const allKnown = new Set([...present, ...everHere])
1024
+ function isAddressee(n) {
1025
+ const lc = n.toLowerCase()
1026
+ if (lc === 'all') return true
1027
+ if (allKnown.has(lc)) return true
1028
+ if (args.needsReply) return true
1029
+ if (isDirectAddress(body, n)) return true
1030
+ if (findClosestKnown(n, [...allKnown])) return true
1031
+ return false
1032
+ }
1033
+ const addressed = named.filter(isAddressee)
1034
+
1035
+ if (!addressed.length && !mentionsAll(body)) {
1036
+ return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
1037
+ `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
1038
+ `agents you actually need with @Name (hive_members lists who is here). Use an ` +
1039
+ `unaddressed message only for a genuine announcement to the whole room.`)
1040
+ }
1041
+
790
1042
  // HIV-P11b. Send-time inspection of recipient state:
791
1043
  // Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
792
1044
  function describeRecipientLiveness(n) {
@@ -801,6 +1053,8 @@ async function dispatch(req) {
801
1053
  present: isPresent,
802
1054
  paused: isPaused,
803
1055
  passive: isPassive,
1056
+ listener: !!(mem && mem.listener),
1057
+ app: mem && mem.app,
804
1058
  now,
805
1059
  parkedAfter,
806
1060
  })
@@ -822,10 +1076,26 @@ async function dispatch(req) {
822
1076
  if (everHere.has(lc)) {
823
1077
  return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
824
1078
  } else {
825
- 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.`
1079
+ // 5.2 Typo catch: within distance 2 of known names
1080
+ const closest = findClosestKnown(n, [...allKnown])
1081
+ if (closest && closest.name.toLowerCase() !== lc) {
1082
+ return `⚠ @${displayName} — not in this room (did you mean @${closest.name}?).`
1083
+ }
1084
+ // 5.2 Direct address position at start of message or explicit needsReply
1085
+ if (isDirectAddress(body, n) || args.needsReply) {
1086
+ // 5.3 Terse 1-liner on subsequent mentions
1087
+ if (firstTime('notInRoom:' + lc)) {
1088
+ 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.`
1089
+ }
1090
+ return `⚠ @${displayName} — not in this room.`
1091
+ }
1092
+ return ''
826
1093
  }
827
1094
  }
828
- // d.state is WORKING or WAITING
1095
+ if (d.state === ACT.WAITING && !d.reachable) {
1096
+ return `⚠ @${displayName} — listener running, but this app is not woken by it.`
1097
+ }
1098
+ // d.state is WORKING or WAITING (reachable)
829
1099
  const busy = findBusy(busyMap, n)
830
1100
  if (busy) {
831
1101
  return describeRecipient(displayName, busy, true, now)
@@ -833,7 +1103,7 @@ async function dispatch(req) {
833
1103
  return ''
834
1104
  }
835
1105
 
836
- const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
1106
+ const candidateNames = addressed.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
837
1107
  const seenCandidates = new Set()
838
1108
  const uniqueCandidates = []
839
1109
  for (const n of candidateNames) {
@@ -844,7 +1114,7 @@ async function dispatch(req) {
844
1114
  }
845
1115
  }
846
1116
  const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
847
- const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
1117
+ const to = mentionsAll(body) ? 'the whole room (@all)' : addressed.map((n) => '@' + n).join(', ')
848
1118
  // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
849
1119
  // HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
850
1120
  // 1. Absent names: warned immediately, ZERO obligations opened.
@@ -900,13 +1170,17 @@ async function dispatch(req) {
900
1170
  const opened = [...openedActive, ...openedAway]
901
1171
 
902
1172
  if (opened.length) {
903
- obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
904
- `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
905
- `a reply does not close it either — you decide whether you were actually answered. If it ` +
906
- `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
1173
+ const ids = opened.map((o) => `${o.id} (${o.to})`).join(', ')
1174
+ obStr += firstTime('obligation')
1175
+ ? `\n\nYOU ARE OWED AN ANSWER — ${ids}\n` +
1176
+ `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
1177
+ `a reply does not close it either — you decide whether you were actually answered. If it ` +
1178
+ `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
1179
+ : `\n\nYOU ARE OWED AN ANSWER — ${ids}. Close it with hive_answered once answered; reminder in ${Math.round(dueMs / 60000)} min.`
907
1180
  }
908
1181
  }
909
- return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
1182
+ // The sender wrote it: echo enough to prove what went out, not all of it again.
1183
+ return text(stamp(`sent to ${to} (${body.length} chars): ${body.length > 90 ? body.slice(0, 90) + '…' : body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
910
1184
  }
911
1185
  case 'hive_busy': {
912
1186
  const s = requireSession()
@@ -938,7 +1212,17 @@ async function dispatch(req) {
938
1212
  return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
939
1213
  }
940
1214
  const n = args.limit || 30
941
- const msgs = all.slice(-n).map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`)
1215
+ // OTHER PEOPLE'S LONG MESSAGES, SHORTENED (27 Sep 2026). Catching up on 30 messages in a room
1216
+ // of multi-paragraph agent reports is tens of thousands of characters, and a result is paid
1217
+ // again on every later turn. Messages addressed to you stay whole; the rest are cut at 280
1218
+ // characters with the length shown; identical runs collapse (x56 conflict storms); full:true
1219
+ // returns everything.
1220
+ const CUT = 280
1221
+ const msgs = collapseRepeats(all.slice(-n)).map((m) => {
1222
+ const t = String(m.text || '')
1223
+ const cut = !args.full && t.length > CUT && !mentionsName(t, me)
1224
+ return formatMessage(cut ? { ...m, text: t.slice(0, CUT) + ` …(+${t.length - CUT} chars; full:true shows it)` } : m)
1225
+ })
942
1226
  return text(msgs.join('\n') || '(no messages yet)')
943
1227
  }
944
1228
  case 'hive_check': {
@@ -955,9 +1239,9 @@ async function dispatch(req) {
955
1239
  // Derived from the log, so it needs no state and cannot drift.
956
1240
  const owed = unansweredFor(all, s.name)
957
1241
  const owedLine = owed.length
958
- ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
959
- `Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
960
- `silence is the one thing they cannot act on.`
1242
+ ? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}.` +
1243
+ (firstTime('owed') ? ` Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
1244
+ `silence is the one thing they cannot act on.` : '')
961
1245
  : ''
962
1246
  if (!got.unreadCount && !tasks.length) {
963
1247
  return text(owed.length
@@ -996,10 +1280,24 @@ async function dispatch(req) {
996
1280
  const dig = ambientDigest(ambient)
997
1281
  if (dig) parts.push(dig)
998
1282
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
999
- return text(parts.join('\n\n') + owedLine)
1283
+ let unsharedPart = ''
1284
+ try {
1285
+ const list = s.hive.unshared ? s.hive.unshared() : []
1286
+ if (list.length) {
1287
+ const unannounced = list.filter((u) => firstTime('unshared:' + u.path))
1288
+ if (unannounced.length) {
1289
+ const shown = unannounced.slice(0, 5)
1290
+ const extra = unannounced.length - shown.length
1291
+ unsharedPart = `\n\n⛔ ${unannounced.length} unshared file(s) on disk (cannot sync to room):\n` +
1292
+ shown.map((u) => ` - ${u.path} (${u.reason}): stays local; share another way (commit it)`).join('\n') +
1293
+ (extra > 0 ? `\n ... (+${extra} more; check hive_status for all)` : '')
1294
+ }
1295
+ }
1296
+ } catch { }
1297
+ return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
1000
1298
  }
1001
1299
  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)') }
1002
- 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') }
1300
+ case 'hive_assign': { enforceDcoLock(); const taskKey = findSecrets(String(args.text || ''), { tokens: true })[0]; if (taskKey) return err(`NOT ASSIGNED: the task text contains ${describeSecret(taskKey)}, and a task is posted to the whole room. Say where the key is kept instead.`); const id = requireSession().hive.assign(String(args.to || ''), String(args.text || '')); return id ? text(`assigned task ${id} to ${args.to} (pending approval if they have an owner)`) : err('need to and text') }
1003
1301
  case 'hive_read_tasks': {
1004
1302
  const all = taskList(); const me = requireSession().name
1005
1303
  const mine = all.filter((t) => t.to === me)
@@ -1007,7 +1305,8 @@ async function dispatch(req) {
1007
1305
  return text(`TASKS FOR YOU (act only on 'accepted'):\n${mine.map(fmtTask).join('\n') || '(none)'}\n\nPENDING (awaiting approval):\n${pending.map(fmtTask).join('\n') || '(none)'}`)
1008
1306
  }
1009
1307
  case 'hive_wait': {
1010
- const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
1308
+ const wp = waitPolicy()
1309
+ const ms = Math.min(Math.max(Number(args.timeoutSeconds) || wp.def, 1), wp.max) * 1000
1011
1310
  // HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
1012
1311
  // point", and the finally is what keeps that true. A wait that returns or throws while
1013
1312
  // leaving the flag set describes an agent parked in a call it has already left -- and
@@ -1031,11 +1330,10 @@ async function dispatch(req) {
1031
1330
  // time AND enough output have passed that it is probably no longer in context.
1032
1331
  const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
1033
1332
  const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
1034
- // The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
1035
- // compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
1036
- // ownership -- so the general rules in the system prompt do not cover them. A pointer is
1037
- // re-sent, never a copy: the file is on this agent's own disk, and a second summary here
1038
- // would be one more description to drift out of date.
1333
+ // The rules went the other way: hive_join hands over their core ONCE (JOIN_RULES; until 27 Sep
1334
+ // 2026 it pasted all 6 KB of HIVE_RULES.md), and compaction reaches that first. When it has
1335
+ // probably been compacted away, a pointer is re-sent, never a copy: HIVE_RULES.md is on this
1336
+ // agent's own disk, and a second summary here would be one more description to drift.
1039
1337
  const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
1040
1338
  ? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
1041
1339
  : ''
@@ -1046,9 +1344,13 @@ async function dispatch(req) {
1046
1344
  // provider reconnects on its own with backoff, so the correct action is to do
1047
1345
  // nothing; the agent just has to be told that, in the one place it is looking.
1048
1346
  const conn = (() => {
1049
- try { return requireSession().hive.status() } catch { return null }
1347
+ try { return connectionOf(requireSession().hive) } catch { return null }
1050
1348
  })()
1051
- const connStr = (conn && !conn.connected)
1349
+ // A lost folder outranks the connection line, and replaces it: that line says never to
1350
+ // rejoin, and a rejoin is the one way back for a folder that is gone.
1351
+ const connStr = (conn && conn.folderLost)
1352
+ ? `\n[FOLDER LOST] ${conn.folderLost} Tell your person.\n`
1353
+ : (conn && !conn.connected)
1052
1354
  ? `\n[CONNECTION] ${conn.connecting ? 'reconnecting' : 'offline'} — this heals itself, the client retries automatically. Do NOT call hive_join; that tears down the session and can overwrite your local edits. Keep calling hive_wait.\n`
1053
1355
  : ''
1054
1356
 
@@ -1091,17 +1393,25 @@ async function dispatch(req) {
1091
1393
  }
1092
1394
  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}`)
1093
1395
  }
1094
- 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}`)
1396
+ if (!r) {
1397
+ const sess = requireSession()
1398
+ const armed = isArmed(sess)
1399
+ const ll = !armed ? listenLine(sess) : ''
1400
+ const waitMsg = armed
1401
+ ? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
1402
+ : '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
1403
+ return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
1404
+ }
1095
1405
  const parts = []
1096
1406
  if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
1097
1407
  const mb = mentionBlock(r); if (mb) parts.push(mb)
1098
1408
  if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
1099
1409
  const ab = ambientBlock(r); if (ab) parts.push(ab)
1100
- return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
1410
+ return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)') + unwritableNotice(requireSession()) + withheldNotice(requireSession()) + collidedNotice(requireSession()))
1101
1411
  }
1102
1412
  case 'hive_delivered': {
1103
1413
  const s2 = requireSession()
1104
- const conn = (() => { try { return s2.hive.status() } catch { return null } })()
1414
+ const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
1105
1415
  const connected = !conn || !!conn.connected
1106
1416
  const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
1107
1417
  const rs = RC.receipts({
@@ -1160,6 +1470,8 @@ async function dispatch(req) {
1160
1470
  present: !!mem,
1161
1471
  paused: !!(c && c.state === 'paused'),
1162
1472
  passive: !!(mem && mem.passive),
1473
+ listener: !!(mem && mem.listener),
1474
+ app: mem && mem.app,
1163
1475
  now,
1164
1476
  parkedAfter,
1165
1477
  })
@@ -1173,6 +1485,9 @@ async function dispatch(req) {
1173
1485
  if (d.state === ACT.PAUSED) {
1174
1486
  return `${base} [PAUSED by human — do not reassign]`
1175
1487
  }
1488
+ if (d.state === ACT.WAITING && !d.reachable) {
1489
+ return `${base} [UNREACHABLE — @${mem ? mem.name : o.to}: listener running, but this app is not woken by it]`
1490
+ }
1176
1491
  return base
1177
1492
  }
1178
1493
 
@@ -1196,11 +1511,24 @@ async function dispatch(req) {
1196
1511
  const f = String(args.file || ''); if (!f) return err('need a file path')
1197
1512
  if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
1198
1513
  const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
1199
- const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines`).join('\n ')
1514
+ if (r.error) return err(r.error)
1515
+ const who = Object.entries(r.peers).map(([n, v]) => {
1516
+ if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
1517
+ if (v.absent) return `${n}: absent (not in folder)`
1518
+ const extra = v.note || ''
1519
+ return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra}`
1520
+ }).join('\n ')
1521
+ const mismatchNote = r.mismatch ? `\n(note: you stated ${r.mismatch.stated}, your folder holds ${r.mismatch.measured})` : ''
1522
+ const lagNotice = (r.lagging && r.lagNotice) ? `\n(note: ${r.lagNotice}, re-attest once disk syncs)` : ''
1200
1523
  if (r.diverged) {
1201
- return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.`)
1524
+ return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.${mismatchNote}${lagNotice}`)
1202
1525
  }
1203
- return text(`attested ${f} at ${args.bytes} bytes.\n ${who}\n${r.count >= 2 ? 'QUORUM: 2+ peers agree — this publish is confirmed.' : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'}`)
1526
+ const quorumStatus = r.quorumOnOlder
1527
+ ? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
1528
+ : r.quorum
1529
+ ? 'QUORUM: 2+ peers agree — this publish is confirmed.'
1530
+ : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'
1531
+ return text(`attested ${f} at ${r.measuredBytes ?? args.bytes} bytes.\n ${who}${mismatchNote}${lagNotice}\n${quorumStatus}`)
1204
1532
  }
1205
1533
  case 'hive_verified': {
1206
1534
  const f = String(args.file || ''); if (!f) return err('need a file path')
@@ -1208,14 +1536,19 @@ async function dispatch(req) {
1208
1536
  // Attestation and gate verdicts are independent records, so an unattested path must still
1209
1537
  // report its gate. Returning early here would hide a FAILED check behind "nobody measured
1210
1538
  // the bytes", which is the opposite of what this tool is for.
1211
- if (!r.count) {
1539
+ if (!r.totalCount) {
1212
1540
  const g0 = requireSession().hive.gateVerdicts(f)
1213
1541
  const extra = Object.entries(g0).map(([n, v]) => v.forced
1214
1542
  ? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
1215
1543
  : `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
1216
1544
  return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
1217
1545
  }
1218
- const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
1546
+ const who = Object.entries(r.peers).map(([n, v]) => {
1547
+ if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
1548
+ if (v.absent) return `${n}: absent (not in folder)`
1549
+ const extra = v.note || ''
1550
+ return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra} at ${v.at}`
1551
+ }).join('\n ')
1219
1552
  // Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
1220
1553
  // WORKS, which is the actual question an agent is asking here — so the gate verdicts
1221
1554
  // belong in this answer. A file two peers agree on and whose owner's tests failed is the
@@ -1227,11 +1560,18 @@ async function dispatch(req) {
1227
1560
  const gate = gLines.length
1228
1561
  ? `\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.' : ''}`
1229
1562
  : '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
1230
- 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}`)
1563
+ const statusLine = r.diverged
1564
+ ? '[!] DIVERGED — peers hold different content. Do not write or build on this path.'
1565
+ : r.quorumOnOlder
1566
+ ? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
1567
+ : r.quorum
1568
+ ? 'QUORUM: confirmed by 2+ peers at the same size.'
1569
+ : 'Attested by ONE peer only — not yet proof.'
1570
+ return text(`${f}\n ${who}\n\n${statusLine}${gate}`)
1231
1571
  }
1232
1572
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
1233
1573
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
1234
- case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
1574
+ case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(r.already ? `${args.id} was already done` : `completed ${args.id}`) : err(r.error) }
1235
1575
  case 'hive_claim': {
1236
1576
  const region = String(args.region || ''); if (!region) return err('need a region (file path)')
1237
1577
  // Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
@@ -1269,11 +1609,12 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
1269
1609
  try { fetched = await requireSession().hive.fetch(region) } catch { }
1270
1610
  const head = `claimed ${region} — it's yours.`
1271
1611
  if (fetched && fetched.notInRoom) {
1272
- return text(`${head}
1273
-
1274
- !! ${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.
1275
-
1276
- Edit it, then call hive_release.`)
1612
+ // Explained once per session: an agent starting a new project claims one new file after
1613
+ // another, and the paragraph cost ~120 tokens each time. The warning itself stays.
1614
+ const why = firstTime('notInRoom')
1615
+ ? `!! ${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.`
1616
+ : `!! ${region} IS NOT IN THIS ROOM yet: claiming it means creating a new file. If you expected content here, the path is outside the room's sync scope.`
1617
+ return text(`${head}\n\n${why}\n\nEdit it, then call hive_release.`)
1277
1618
  }
1278
1619
  if (fetched && fetched.ok === false) {
1279
1620
  return text(`${head}
@@ -1318,6 +1659,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1318
1659
  }
1319
1660
  const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
1320
1661
  if (r.error) return err(r.error)
1662
+ if (r.withheld) {
1663
+ firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
1664
+ return text(`⛔ wrote ${r.file} in YOUR folder only. It was NOT shared, because it contains ${r.withheld}, and Hivecode does not send keys to the room or the relay. Nobody else has this change. Read the key from the environment instead (put it in .env, which is never shared); the file is shared on its next save without the key.`)
1665
+ }
1321
1666
  return text(r.conflicted
1322
1667
  ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
1323
1668
  : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
@@ -1352,8 +1697,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1352
1697
  const rec = act ? act.get(String(m.name).toLowerCase()) : null
1353
1698
  const c = controls.get(m.name)
1354
1699
  const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
1355
- return ACT.line(`${m.name} (${m.kind})`, rec, {
1700
+ return ACT.line(`${m.name} (${m.kind})${m.hv ? ` on ${m.hv}` : ''}`, rec, {
1356
1701
  present: true, passive: !!m.passive,
1702
+ listener: !!m.listener,
1703
+ app: m.app,
1357
1704
  paused: !!(c && c.state === 'paused'),
1358
1705
  // HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
1359
1706
  // send it now will sit unread until a human prompts it` about the human.
@@ -1384,21 +1731,28 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1384
1731
  // agent asks here, and only one of them had an answer: "am I reaching anyone" was
1385
1732
  // reportable and "where is my source going" was not. A bare relay URL is not an
1386
1733
  // answer to the second -- the public relay and a company's own are the same shape.
1387
- const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
1734
+ const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
1388
1735
  return text({
1389
- room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
1736
+ room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
1737
+ ...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
1390
1738
  privacy: pv.headline,
1739
+ access: isSecuredRoom(s.room) ? (loadRoomKey(s.room) ? 'personal: invite only, and you host it (npx -y hivecode-mcp@latest invite <name>)' : 'personal: invite only') : 'open: anyone with the link can join',
1391
1740
  ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1392
1741
  ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1393
1742
  unread: mailbox().peek(chatArray()).unreadCount,
1394
1743
  connected: conn ? conn.connected : null,
1395
1744
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
1745
+ ...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
1746
+ ...(conn && conn.unsharedFiles && conn.unsharedFiles.length ? { unshared: conn.unsharedFiles } : {}),
1747
+ ...(conn && conn.unwritable && conn.unwritable.length ? { unwritable: { files: conn.unwritable.map((u) => `${u.path} (${u.code})`), meaning: 'read-only or locked in your folder, so your copy is older than the room\'s; each is written as soon as it can be' } } : {}),
1748
+ ...(conn && conn.collided && conn.collided.length ? { collided: { files: conn.collided.map((c) => `${c.path} (your folder has ${c.with})`), meaning: 'in the room, but this disk cannot hold both names, so not in your folder; editing yours never changes it' } } : {}),
1749
+ ...(conn && conn.withheld && conn.withheld.length ? { withheld: { files: conn.withheld.map((w) => `${w.path}: ${w.kind}, line ${w.line}`), meaning: 'NOT shared: each contains a key or token the room does not have. Read it from the environment (.env is never shared); the file is shared on its next save without it' } } : {}),
1396
1750
  })
1397
1751
  }
1398
1752
  // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1399
1753
  // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1400
1754
  // and diluting it with agents that signed off correctly is how a signal stops being read.
1401
- case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1755
+ case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
1402
1756
  default: return err(`unknown tool: ${name}`)
1403
1757
  }
1404
1758
  } catch (e) { return err(`error: ${e.message}`) }
@@ -1456,8 +1810,16 @@ let mailNotice = 0
1456
1810
  function mailBanner() {
1457
1811
  try {
1458
1812
  if (!session || !session.mailbox) return ''
1813
+ catchUp(session)
1459
1814
  const m = session.mailbox.peek(chatArray())
1460
- const frame = (body) => `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE above) ---\n${body}\n--- end notice #${mailNotice} ---`
1815
+ // EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
1816
+ // mail" can only be retired by a newer notice that says so. What is said once per session (see
1817
+ // firstTime) is the explanation in the body.
1818
+ const first = !explained.has('mailnotice')
1819
+ const frame = (body) => {
1820
+ explained.add('mailnotice')
1821
+ return `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE) ---\n${body}`
1822
+ }
1461
1823
 
1462
1824
  if (!m.mentioned) {
1463
1825
  // Emit the all-clear once, and only if we had previously announced something. Without this the
@@ -1465,7 +1827,9 @@ function mailBanner() {
1465
1827
  if (!mailSig) return ''
1466
1828
  mailSig = ''
1467
1829
  mailNotice += 1
1468
- return frame('MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.')
1830
+ return frame(first
1831
+ ? 'MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.'
1832
+ : 'MAIL CLEARED: nothing addressed to you; every earlier notice is STALE.')
1469
1833
  }
1470
1834
 
1471
1835
  const last = m.mentions[m.mentions.length - 1] || {}
@@ -1479,7 +1843,7 @@ function mailBanner() {
1479
1843
  const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
1480
1844
  const urgent = m.priority === 'stop'
1481
1845
  return frame(`${urgent ? '[!] URGENT MAIL' : 'UNREAD MAIL'}: ${m.mentions.length} message(s) addressed to you from ${who}. `
1482
- + `${urgent ? 'Handle before continuing. ' : ''}Call hive_wait or hive_check to read them — this notice consumed nothing.`)
1846
+ + `${urgent ? 'Handle before continuing. ' : ''}${first ? 'Call hive_wait or hive_check to read them — this notice consumed nothing.' : 'hive_check reads them.'}`)
1483
1847
  } catch { return '' }
1484
1848
  }
1485
1849
 
@@ -1558,6 +1922,32 @@ if (isEntryPoint && process.argv[2] === 'setup') {
1558
1922
  } else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
1559
1923
  console.log(VERSION)
1560
1924
  process.exit(0)
1925
+ } else if (isEntryPoint && (process.argv[2] === 'branch' || process.argv[2] === 'pr')) {
1926
+ // `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
1927
+ // A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
1928
+ process.exit(await HB.main(process.argv.slice(2)))
1929
+ } else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
1930
+ // `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
1931
+ // takes it back. Commands rather than tools: see hive-room-key.js for why.
1932
+ process.exit(await roomKeyMain(process.argv.slice(2)))
1933
+ } else if (isEntryPoint && (process.argv[2] === 'github' || process.argv[2] === 'notify' || process.argv[2] === 'slack')) {
1934
+ // `github start|status`: issues labelled hivecode come back as pull requests (hive-github.js).
1935
+ // `notify "<link>"`: a person hears on Slack or Discord when an agent is waiting on them
1936
+ // (hive-notify.js). `slack start "<link>" --channel <id>` or `slack check --channel <id>`:
1937
+ // threads per task in Slack and end-to-end setup check (hive-slack.js).
1938
+ // `slack agent "<link>" --channel <id>`: Slack assistant panel and 1:1 DMs (hive-slack-agent.js).
1939
+ // `slack mcp-server "<link>" --port <port>`: Streamable HTTP MCP server for Slackbot (hive-mcp-http.js).
1940
+ if (process.argv[2] === 'slack' && process.argv[3] === 'agent') {
1941
+ const mod = await import('./hive-slack-agent.js')
1942
+ process.exit(await mod.main(process.argv.slice(4)))
1943
+ } else if (process.argv[2] === 'slack' && process.argv[3] === 'mcp-server') {
1944
+ const mod = await import('./hive-mcp-http.js')
1945
+ process.exit(await mod.main(process.argv.slice(4)))
1946
+ } else {
1947
+ const modPath = process.argv[2] === 'github' ? './hive-github.js' : process.argv[2] === 'slack' ? './hive-slack.js' : './hive-notify.js'
1948
+ const mod = await import(modPath)
1949
+ process.exit(await mod.main(process.argv.slice(3)))
1950
+ }
1561
1951
  } else if (isEntryPoint) {
1562
1952
  const transport = new StdioServerTransport()
1563
1953
  // STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
@@ -1574,7 +1964,54 @@ if (isEntryPoint && process.argv[2] === 'setup') {
1574
1964
  }
1575
1965
 
1576
1966
  /** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
1967
+ // THE IDLE LOOP, BY APP (27 Sep 2026). Every hive_wait that times out is a whole model turn spent
1968
+ // learning that nothing happened, so the default decides how many paid turns an idle agent burns --
1969
+ // a night shift is hundreds -- and each app bounds a tool call differently:
1970
+ // Claude Code a call may run ~28 h, but one past 2 min moves to a background task
1971
+ // (code.claude.com/docs/en/mcp): 110 s stays an ordinary blocking call, ~45% fewer
1972
+ // idle turns than 60 s.
1973
+ // Antigravity a HARD 3-minute tool deadline. Measured by Antigravity1: 110 s ran to the end, 300 s
1974
+ // died at exactly 180 s ("context deadline exceeded"), an error instead of a wait. So
1975
+ // 110 s by default and never more than 170 s there.
1976
+ // Apps we have not measured keep 60 s and the 300 s ceiling. The names are matched loosely; a miss
1977
+ // only means the old numbers.
1978
+ function clientName() {
1979
+ const c = (server && server.getClientVersion && server.getClientVersion()) || {}
1980
+ return String(c.name || '')
1981
+ }
1982
+ function waitPolicy() {
1983
+ const n = clientName()
1984
+ if (/claude-code/i.test(n)) return { def: 110, max: 300 }
1985
+ if (/antigravity/i.test(n)) return { def: 110, max: 170 }
1986
+ return { def: 60, max: 300 }
1987
+ }
1988
+ function waitDefaultSeconds() { return waitPolicy().def }
1989
+
1990
+ // THE LINE THAT KEEPS A CLAUDE CODE AGENT REACHABLE AFTER ITS TURN (2 Oct 2026). The rules say
1991
+ // hive_wait is the idle state, but agents end their turns anyway, and from then on they are deaf:
1992
+ // the Lead's #1 complaint in the 4-agent KCDE run, and Antigravity1 sat PARKED with a review unread
1993
+ // the same night. Claude Code wakes an agent when one of its background tasks ends (so does
1994
+ // Antigravity: see listenHow), so hive-await running in the background turns "ended its turn" into
1995
+ // "asleep until called". Nothing told a stranger's agent that. It is said once, on the first empty wait, and as
1996
+ // an order: an agent told to "consider" a command tends to describe it instead of running it. The
1997
+ // idle limit is 25 minutes because Claude Code stops a background task after about 30 (measured
1998
+ // 2 Oct: started 03:46, killed 04:16) and then tells the agent not to restart it; a listener that
1999
+ // ends itself on idle can be restarted. Both paths are this server's own, so the listener is the
2000
+ // same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
2001
+ // room gets no line: the agent's shell does not hold this server's token, and a listener that
2002
+ // cannot join is a false "listening". (tests/mcp-wait-test.mjs)
2003
+ function listenLine(s) {
2004
+ // A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
2005
+ const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
2006
+ if (!how) return ''
2007
+ const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name })
2008
+ return `\n${listenOrder(how, cmd)}\n`
2009
+ }
2010
+
1577
2011
  export const _test = {
2012
+ waitDefaultSeconds,
2013
+ waitPolicy,
2014
+ initialize: (params = {}) => server._requestHandlers.get('initialize')({ method: 'initialize', params }, {}),
1578
2015
  waitForWork,
1579
2016
  defaultJoinTarget,
1580
2017
  mailbox,
@@ -1584,6 +2021,11 @@ export const _test = {
1584
2021
  throttleStats: () => throttle.stats(),
1585
2022
  setSession(s) { session = s },
1586
2023
  getSession() { return session },
2024
+ tryResumeSession,
2025
+ // One process serves one app, so "said once" is per process; a test that plays two apps in one
2026
+ // process forgets between them.
2027
+ forget: (key) => explained.delete(key),
2028
+ mixedLine,
1587
2029
  callTool: (name, args = {}) =>
1588
2030
  server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
1589
2031
  }