hivecode-mcp 0.11.6 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hive-mcp.js CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Register it with an MCP client (e.g. Claude Code / Claude Desktop):
6
6
  // command: node args: ["<abs-path>/hive-mcp.js"]
7
- // env (optional): HIVE_RELAY = wss://your-relay (defaults to the hosted relay)
7
+ // env (optional): HIVE_RELAY = wss://your-relay (defaults to PUBLIC_DEFAULT_RELAY in hive-privacy.js)
8
8
  //
9
9
  // Tools exposed:
10
10
  // hive_join join/host a room for a folder (auto kind:ai) -> returns the rules
@@ -20,12 +20,14 @@
20
20
  // it returns (read chat + board before editing, announce intent, prefer patches).
21
21
 
22
22
  import fs from 'fs'
23
+ import os from 'os'
23
24
  import path from 'path'
24
25
  import crypto from 'crypto'
25
26
  import { fileURLToPath } from 'url'
26
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
27
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
28
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
27
+ // The stdio server is our own, not @modelcontextprotocol/sdk's: the SDK drags express, hono, zod
28
+ // and ~85 more packages into every install for a slice this small. See hive-stdio.js.
29
+ import { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } from './hive-stdio.js'
30
+ import { startUpdateCheck, updateNotice } from './hive-update.js'
29
31
  // Substrate defaults for MCP agents: signed provenance + the silent-fork gate are ON
30
32
  // unless explicitly disabled. Agents register this server with arbitrary (often empty)
31
33
  // env, so relying on per-registration env vars left rooms in mixed signed/unsigned mode —
@@ -34,14 +36,17 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
36
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
37
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
38
  import { startSync, parseLink } from './sync.js'
39
+ import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
37
40
  import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
38
41
  import { INSTRUCTIONS } from './hive-instructions.js'
39
42
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
43
+ import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
44
+ import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
40
45
  import * as OB from './hive-obligations.js'
41
46
  import * as RC from './hive-receipts.js'
42
47
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
43
48
 
44
- const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
49
+ const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
45
50
  let session = null // { hive, room, relay, dir, name, mailbox }
46
51
 
47
52
  // ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
@@ -60,7 +65,16 @@ const text = (s) => {
60
65
  }
61
66
  const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
62
67
 
68
+ // Every route to a relay passes through here, so a retired public relay is followed to its
69
+ // replacement in one place -- whether it arrived in a pasted link, a .hive.json written before
70
+ // the move, or a HIVE_RELAY our own docs once told people to set. See currentRelayFor().
63
71
  function resolveRoom(link, dir) {
72
+ const r = resolveRoomRaw(link, dir)
73
+ const { relay, migratedFrom } = currentRelayFor(r.relay)
74
+ return { ...r, relay, migratedFrom }
75
+ }
76
+
77
+ function resolveRoomRaw(link, dir) {
64
78
  if (link) { const { relay, room, token } = parseLink(link); return { relay: relay || DEFAULT_RELAY, room, token, mode: 'joined (link)' } }
65
79
  const cfgPath = path.join(path.resolve(dir), '.hive.json')
66
80
  if (fs.existsSync(cfgPath)) {
@@ -102,7 +116,49 @@ async function tryResumeSession() {
102
116
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
103
117
  }
104
118
 
105
- async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
119
+ // WHERE AN AGENT'S FILES GO WHEN NOBODY SAID. This was './workspace', resolved against the
120
+ // directory the agent app happened to start this server in: the user's project for Claude Code,
121
+ // something unwritable or unguessable for others -- and the SAME folder for two agents started
122
+ // alike, which is the one thing a room must never have (one folder per participant). Now it is
123
+ // ~/hivecode/<room>/<name>: absolute, writable, and different for every name.
124
+ //
125
+ // One exception keeps returning users whole: an agent that joined the old way and now calls
126
+ // hive_join with nothing, expecting its room back from ./workspace/.hive.json, still gets it.
127
+ // Sending it to a new folder instead would host a brand-new room and report success -- the agent
128
+ // would be alone, told it was joined.
129
+ const HIVE_HOME = process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
130
+ const LEGACY_DIR = './workspace'
131
+ function safeSegment(s) {
132
+ const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
133
+ return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
134
+ }
135
+ function roomIn(dir) {
136
+ try { return JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.hive.json'), 'utf8')).room || null } catch { return null }
137
+ }
138
+ function defaultJoinTarget(link, name) {
139
+ // The folders this agent already has a room in: the one its last session used (hive_leave
140
+ // deletes that record, so it only survives a disconnect or a restart), then the old default.
141
+ const prev = readSessionFile()
142
+ const prevDir = prev && prev.dir && roomIn(prev.dir) ? prev.dir : null
143
+ const legacy = roomIn(LEGACY_DIR) ? LEGACY_DIR : null
144
+ if (!link) {
145
+ // "No link, no folder" has always meant "my room, if I have one". Hosting a new one instead
146
+ // would leave an agent that meant to rejoin alone in an empty room, told it had joined.
147
+ const back = prevDir || legacy
148
+ if (back) return { dir: back, link, hostedHere: false }
149
+ const room = 'room-' + crypto.randomBytes(13).toString('base64url')
150
+ return { dir: path.join(HIVE_HOME, room, safeSegment(name)), link: `${DEFAULT_RELAY}|${room}`, hostedHere: true }
151
+ }
152
+ const room = parseLink(link).room
153
+ if (!room) return { dir: LEGACY_DIR, link, hostedHere: false }
154
+ // Rejoining a room this agent was already in lands in the folder it used, files and all. The
155
+ // session record must be under THIS name: two runtimes started in one directory share it.
156
+ if (prevDir && prev.name === name && roomIn(prevDir) === room) return { dir: prevDir, link, hostedHere: false }
157
+ if (legacy && roomIn(legacy) === room) return { dir: legacy, link, hostedHere: false }
158
+ return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
159
+ }
160
+
161
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
106
162
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
107
163
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
108
164
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -115,14 +171,41 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
115
171
  This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
116
172
  }
117
173
  if (session) { try { session.hive.stop() } catch {} ; session = null }
174
+ let hostedHere = false
175
+ if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
118
176
  const ROOT = path.resolve(dir)
119
177
  fs.mkdirSync(ROOT, { recursive: true })
120
- const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
178
+ const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
179
+ const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
180
+ // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
181
+ // .hive.json write below and before startSync opens a socket. A refused join must leave
182
+ // nothing behind -- no config pointing at the wrong relay for the next process to find,
183
+ // and above all no published file. Checking after connecting would report a leak instead
184
+ // 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 })
186
+ if (!priv.ok) {
187
+ return `${priv.headline}
188
+
189
+ ${priv.detail}
190
+
191
+ room: ${room}
192
+ mode: ${mode}
193
+ folder: ${ROOT}
194
+
195
+ NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
196
+ was not written. Fix the relay and call hive_join again.`
197
+ }
121
198
  // token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
122
199
  const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
123
200
  // persist the token too, so a re-join from .hive.json keeps the grant (the
124
201
  // invitee pastes the link once; subsequent joins just read the folder config).
125
- fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
202
+ // pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
203
+ // kept in .hive.json -- one of the two places readPin() looks -- was erased by the very first
204
+ // join that honoured it. Only the pin is carried over: an old token must not follow the
205
+ // folder into a different room.
206
+ let keep = {}
207
+ try { const old = JSON.parse(fs.readFileSync(path.join(ROOT, '.hive.json'), 'utf8')); if (old && old.pinnedRelay) keep = { pinnedRelay: old.pinnedRelay } } catch { /* none yet */ }
208
+ fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}), ...keep }, null, 2))
126
209
  // The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
127
210
  // asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
128
211
  // and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
@@ -133,7 +216,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
133
216
  // folder is precisely the situation the lock exists to prevent.
134
217
  let hive
135
218
  try {
136
- hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
219
+ hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
137
220
  } catch (e) {
138
221
  if (e && e.code === 'EHIVELOCKED') {
139
222
  return text(
@@ -143,7 +226,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
143
226
  }
144
227
  throw e
145
228
  }
146
- session = { hive, room, relay, dir, name, owner, mailbox: null }
229
+ session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
147
230
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
148
231
  // Wait for the first sync so members/board/rules are ready.
149
232
  //
@@ -156,6 +239,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
156
239
  // here, at the moment we know whether the socket came up.
157
240
  let synced = false
158
241
  let connErr = ''
242
+ let refusedCode = null
159
243
  await new Promise((resolve) => {
160
244
  let done = false
161
245
  const finish = () => { if (!done) { done = true; resolve() } }
@@ -163,13 +247,35 @@ This is refused rather than fixed for you because renaming silently is worse: if
163
247
  // A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
164
248
  // Q-code and destroys the socket, so this event is the only place the reason is visible.
165
249
  hive.provider.on('connection-error', (ev) => {
166
- const code = ev && (ev.code || ev.status || ev.type)
250
+ const code = RH.statusFromError(ev) || (ev && (ev.code || ev.status || ev.type))
251
+ if (code && typeof code === 'number') refusedCode = code
167
252
  if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
168
253
  })
169
254
  setTimeout(finish, 8000) // don't hang if the relay is cold
170
255
  })
171
256
  if (!synced) {
257
+ // HIV-P12. EVIDENCE BEFORE INFERENCE.
258
+ // If the relay actually refused us with an HTTP status, explainStatus() maps it to the
259
+ // authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
260
+ // Only when nothing refused us with a code do we query /health to infer whether it was capacity.
261
+ if (refusedCode) {
262
+ const explanation = RH.explainStatus(refusedCode, relay)
263
+ return [
264
+ `NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
265
+ `room: ${room}`,
266
+ `relay: ${relay}`,
267
+ `folder: ${ROOT}`,
268
+ '',
269
+ 'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
270
+ 'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
271
+ 'you do now is invisible to the team and can be silently overwritten later.',
272
+ '',
273
+ 'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
274
+ 'it reports `connected` and `filesSynced`, and both are authoritative.',
275
+ ].join('\n')
276
+ }
172
277
  const why = connErr || 'no sync within 8s -- the relay did not answer'
278
+ const diag = await RH.explainNotSyncing(relay, { what: 'join' })
173
279
  return [
174
280
  `NOT JOINED -- ${why}.`,
175
281
  `room: ${room}`,
@@ -180,10 +286,9 @@ This is refused rather than fixed for you because renaming silently is worse: if
180
286
  'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
181
287
  'you do now is invisible to the team and can be silently overwritten later.',
182
288
  '',
183
- 'Most likely causes, in the order worth checking:',
184
- ' - the relay is at capacity (Q430). Hivecode opens one socket PER FILE, so a large',
185
- ' folder can exhaust it by itself. Check <relay-http>/health: connections vs',
186
- ' limits.maxConnsTotal. Raise HIVE_MAX_CONNS_TOTAL, or narrow what syncs via .gitignore.',
289
+ diag.text,
290
+ '',
291
+ 'Two causes the relay cannot tell you about, worth checking if the above says capacity is fine:',
187
292
  ' - the relay is not running, or the port/room in the link is wrong.',
188
293
  ' - the room is secured and this link carries no token (Q4xx on connect).',
189
294
  '',
@@ -283,7 +388,18 @@ This is refused rather than fixed for you because renaming silently is worse: if
283
388
  // The invite link carries the token when there is one — without it a secured room rejects
284
389
  // whoever you hand it to, and the Control Room says the link is missing its token.
285
390
  const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
286
- return `${mode}\nroom: ${room}\nrelay: ${relay}\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
391
+ // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
392
+ // who has not run a relay -- the public one and a company's own look identical. The
393
+ // bracketed verdict is what makes "did my source just leave the building?" answerable
394
+ // without asking, and the warning line below is what makes it actionable.
395
+ const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
396
+ // Said out loud, because changing where someone's files go without telling them is the thing
397
+ // this whole module exists to prevent -- even when the move is to where they meant to be.
398
+ const movedNote = migratedFrom
399
+ ? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
400
+ `It now uses ${relay}, and .hive.json has been updated, so this happens once.`
401
+ : ''
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}`
287
403
  }
288
404
 
289
405
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
@@ -298,6 +414,10 @@ function mailbox() {
298
414
  return s.mailbox
299
415
  }
300
416
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
417
+
418
+ // HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
419
+ // observation about a tool call, not part of it, and it must never be the reason a tool fails.
420
+ function activityMap() { try { return requireSession().hive.doc.getMap('activity') } catch { return null } }
301
421
  // Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
302
422
  // keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
303
423
  function findBusy(map, name) {
@@ -421,7 +541,7 @@ function waitForWork(timeoutMs, opts = {}) {
421
541
 
422
542
  const TOOLS = [
423
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.',
424
- inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' } } } },
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' } } } },
425
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.',
426
546
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
427
547
  { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
@@ -586,13 +706,8 @@ function enforceDcoLock(opts = {}) {
586
706
  // itself reporting the wrong build. Read the truth instead; package.json always ships in the
587
707
  // tarball and sits beside this file in both the repo and the published package.
588
708
  // A failed read must never stop the server from starting, so it falls back rather than throwing.
589
- export const VERSION = (() => {
590
- try {
591
- const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
592
- if (typeof v === 'string' && v) return v
593
- } catch { /* fall through */ }
594
- return '0.0.0-unknown'
595
- })()
709
+ import { VERSION } from "./hive-version.js"
710
+ export { VERSION }
596
711
  // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
597
712
  // makes it the only channel that reaches an agent before its first tool call. Everything else we
598
713
  // have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
@@ -611,6 +726,21 @@ async function dispatch(req) {
611
726
  // persisted session exists for this cwd, transparently rejoin before any tool runs —
612
727
  // the agent (and its human) never see a "not in a room" flake again.
613
728
  if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
729
+ // HIV-P11. STAMPED BEFORE THE TOOL RUNS, not after it returns.
730
+ //
731
+ // The first version stamped in a `finally` -- which is wrong by exactly one call, and the
732
+ // suite caught it: an agent that called hive_members saw ITSELF reported PARKED, because the
733
+ // record it read still held the timestamp of its previous call. The activity is the call
734
+ // BEGINNING; a tool that takes four minutes is working for all four of them, and a tool that
735
+ // throws was still an agent doing something. Stamping first covers both, and covers every
736
+ // tool anyone adds later, because there is no path into the switch that skips this line.
737
+ //
738
+ // hive_wait stamps its own entry and exit (waitingSince must mean "inside the call RIGHT
739
+ // NOW"). hive_leave drops the row instead of refreshing it. hive_join has no session yet, so
740
+ // it is stamped in the finally below -- the one case where after is the only option.
741
+ if (session && name !== 'hive_wait' && name !== 'hive_leave') {
742
+ try { ACT.noteAction(activityMap(), { name: session.name, tool: name }) } catch { }
743
+ }
614
744
  switch (name) {
615
745
  case 'hive_join': return text(await joinRoom(args))
616
746
  case 'hive_say': {
@@ -643,58 +773,137 @@ async function dispatch(req) {
643
773
  // reply: it arrives before the sender starts waiting, and it costs the room no message.
644
774
  // This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
645
775
  const s = requireSession()
646
- const present = new Set(s.hive.members().map((m) => String(m.name).toLowerCase()))
776
+ const members = s.hive.members()
777
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
778
+ const present = new Set(members.map((m) => String(m.name).toLowerCase()))
647
779
  const busyMap = s.hive.doc.getMap('busy')
648
- const notes = named
649
- .filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
650
- .map((n) => describeRecipient(n, findBusy(busyMap, n), present.has(n.toLowerCase())))
651
- .filter(Boolean)
780
+ const act = activityMap()
781
+ const controls = s.hive.doc.getMap('controls')
782
+ const parkedAfter = ACT.parkedAfterMs()
783
+ const now = Date.now()
784
+
785
+ const everHere = new Set(present)
786
+ for (const m of s.hive.doc.getArray('chat').toArray()) {
787
+ if (m && m.by) everHere.add(String(m.by).toLowerCase())
788
+ }
789
+
790
+ // HIV-P11b. Send-time inspection of recipient state:
791
+ // Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
792
+ function describeRecipientLiveness(n) {
793
+ const lc = n.toLowerCase()
794
+ const mem = memberMap.get(lc)
795
+ const isPresent = !!mem
796
+ const rec = act ? act.get(lc) : null
797
+ const c = controls.get(mem ? mem.name : n)
798
+ const isPaused = !!(c && c.state === 'paused')
799
+ const isPassive = !!(mem && mem.passive)
800
+ const d = ACT.describe(rec, {
801
+ present: isPresent,
802
+ paused: isPaused,
803
+ passive: isPassive,
804
+ now,
805
+ parkedAfter,
806
+ })
807
+ const displayName = mem ? mem.name : n
808
+
809
+ if (d.state === ACT.PARKED) {
810
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
811
+ const tool = d.lastTool ? ` (${d.lastTool})` : ''
812
+ return `⚠ @${displayName} — PARKED: connected, but NOT inside hive_wait and silent for ${age}${tool}. ` +
813
+ `It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
814
+ }
815
+ if (d.state === ACT.PAUSED) {
816
+ return `⚠ @${displayName} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
817
+ }
818
+ if (d.state === ACT.PASSIVE) {
819
+ return `⚠ @${displayName} — PASSIVE LISTENER. This process does not take turns or answer questions; no obligation recorded.`
820
+ }
821
+ if (d.state === ACT.GONE) {
822
+ if (everHere.has(lc)) {
823
+ return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
824
+ } 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.`
826
+ }
827
+ }
828
+ // d.state is WORKING or WAITING
829
+ const busy = findBusy(busyMap, n)
830
+ if (busy) {
831
+ return describeRecipient(displayName, busy, true, now)
832
+ }
833
+ return ''
834
+ }
835
+
836
+ const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
837
+ const seenCandidates = new Set()
838
+ const uniqueCandidates = []
839
+ for (const n of candidateNames) {
840
+ const lc = n.toLowerCase()
841
+ if (!seenCandidates.has(lc)) {
842
+ seenCandidates.add(lc)
843
+ uniqueCandidates.push(n)
844
+ }
845
+ }
846
+ const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
652
847
  const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
653
- // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named,
654
- // and `absent` is stamped NOW -- because "was anyone even there when I asked" is a fact
655
- // about the past, and recomputing it later would let a question asked into an empty room
656
- // start looking fine the moment somebody walks in. This is the 19 Sep 2026 failure
657
- // exactly: one message to @Reviewer, who had never been in the room, then six hours of
658
- // silence that no tool, log or process reported.
848
+ // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
849
+ // HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
850
+ // 1. Absent names: warned immediately, ZERO obligations opened.
851
+ // 2. Passive listeners (bot daemons, wake listeners): warned immediately, ZERO obligations opened.
852
+ // 3. Active participants: real debt opened, dischargeable ONLY by the asker.
853
+ // 4. @all broadcast: targets only active non-passive AI agents in the room.
659
854
  let obStr = ''
660
855
  if (args.needsReply) {
661
856
  const obMap = s.hive.doc.getMap('obligations')
662
- // Floor in MILLISECONDS, not minutes. `Math.max(1, mins)` silently turned a caller's
663
- // 0.05 into 1 -- a clamp that changes what was asked for and says nothing, which is the
664
- // same shape as every other defect on this project. One second is enough to stop a
665
- // zero-or-negative busy loop, and everything above it is honoured as written.
666
857
  const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
667
- const targets = mentionsAll(body)
668
- ? s.hive.members().map((m) => String(m.name)).filter((n) => n.toLowerCase() !== s.name.toLowerCase())
669
- : named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
670
- // PRESENCE AND PROVENANCE ARE DIFFERENT FACTS AND THEY DECIDE DIFFERENT THINGS.
671
- // `absent` decides the WARNING: nobody by that name is connected right now, so say so
672
- // at once. `neverSeen` decides the LIFETIME: nobody by that name has ever joined or
673
- // spoken here, so this is noise rather than a teammate who stepped out.
674
- // Conflating them would expire a real debt: on 20 Sep an obligation against
675
- // @DeepMind-Agent was stamped absent because it had dropped between turns, and it came
676
- // back ninety minutes later and answered. A ten-minute timer would have deleted it, and
677
- // the ledger would have been at its cleanest exactly while an agent was silent.
678
- const everHere = new Set([...present])
679
- for (const m of s.hive.doc.getArray('chat').toArray()) {
680
- if (m && m.by) everHere.add(String(m.by).toLowerCase())
858
+
859
+ let activeTargets = []
860
+ let awayTargets = []
861
+ let neverSeenNames = []
862
+ let passiveNames = []
863
+
864
+ if (mentionsAll(body)) {
865
+ activeTargets = members
866
+ .filter((m) => String(m.name).toLowerCase() !== s.name.toLowerCase() && !m.passive && m.kind === 'ai')
867
+ .map((m) => String(m.name))
868
+ } else {
869
+ for (const n of uniqueCandidates) {
870
+ const lc = n.toLowerCase()
871
+ const mem = memberMap.get(lc)
872
+ if (mem) {
873
+ if (mem.passive) {
874
+ passiveNames.push(mem.name || n)
875
+ } else {
876
+ activeTargets.push(mem.name || n)
877
+ }
878
+ } else {
879
+ if (everHere.has(lc)) {
880
+ awayTargets.push(n)
881
+ } else {
882
+ neverSeenNames.push(n)
883
+ }
884
+ }
885
+ }
681
886
  }
682
- const opened = targets.map((n) => OB.open(obMap, {
887
+
888
+ const openedActive = activeTargets.map((n) => OB.open(obMap, {
889
+ from: s.name, to: n, text: body, dueMs,
890
+ absent: false,
891
+ neverSeen: false,
892
+ }))
893
+
894
+ const openedAway = awayTargets.map((n) => OB.open(obMap, {
683
895
  from: s.name, to: n, text: body, dueMs,
684
- absent: !present.has(n.toLowerCase()),
685
- neverSeen: !everHere.has(n.toLowerCase()),
896
+ absent: true,
897
+ neverSeen: false,
686
898
  }))
899
+
900
+ const opened = [...openedActive, ...openedAway]
901
+
687
902
  if (opened.length) {
688
- obStr = `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
903
+ obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
689
904
  `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
690
905
  `a reply does not close it either — you decide whether you were actually answered. If it ` +
691
906
  `is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
692
- const ghosts = opened.filter((o) => o.absent)
693
- if (ghosts.length) {
694
- obStr += `\n\n⚠ ${ghosts.map((o) => '@' + o.to).join(', ')} — NOT IN THIS ROOM. Nobody by that ` +
695
- `name is here, so nobody is going to answer. Call hive_members and re-address it now; ` +
696
- `this is the one failure mode that otherwise costs you a whole shift.`
697
- }
698
907
  }
699
908
  }
700
909
  return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
@@ -799,7 +1008,21 @@ async function dispatch(req) {
799
1008
  }
800
1009
  case 'hive_wait': {
801
1010
  const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
802
- const r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
1011
+ // HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
1012
+ // point", and the finally is what keeps that true. A wait that returns or throws while
1013
+ // leaving the flag set describes an agent parked in a call it has already left -- and
1014
+ // describes it as WAITING, the single most reassuring state in the taxonomy. stateOf()
1015
+ // gates WAITING on awareness so a process that DIES here cannot fake it, but a live
1016
+ // process that merely threw is still present, so the gate does not cover that and this
1017
+ // does. The name is captured first because the session can be gone by the finally.
1018
+ const waitName = requireSession().name
1019
+ ACT.noteWaitStart(activityMap(), { name: waitName })
1020
+ let r
1021
+ try {
1022
+ r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
1023
+ } finally {
1024
+ try { ACT.noteWaitEnd(activityMap(), { name: waitName }) } catch { }
1025
+ }
803
1026
  const globalState = requireSession().hive.getState()
804
1027
  // THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
805
1028
  // sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
@@ -918,16 +1141,52 @@ async function dispatch(req) {
918
1141
  case 'hive_owed': {
919
1142
  const s2 = requireSession()
920
1143
  const obMap = s2.hive.doc.getMap('obligations')
1144
+ const act = activityMap()
1145
+ const parkedAfter = ACT.parkedAfterMs()
1146
+ const members = s2.hive.members()
1147
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
1148
+ const controls = s2.hive.doc.getMap('controls')
1149
+ const now = Date.now()
1150
+
1151
+ // HIV-P11b. Dynamic reachability annotation for debts:
1152
+ // Distinguishes actionable pending debts from debts owed by PARKED, GONE, or PAUSED agents.
1153
+ function describeDebt(o) {
1154
+ const base = OB.describe(o, () => now)
1155
+ const lc = String(o.to || '').toLowerCase()
1156
+ const mem = memberMap.get(lc)
1157
+ const rec = act ? act.get(lc) : null
1158
+ const c = controls.get(mem ? mem.name : o.to)
1159
+ const d = ACT.describe(rec, {
1160
+ present: !!mem,
1161
+ paused: !!(c && c.state === 'paused'),
1162
+ passive: !!(mem && mem.passive),
1163
+ now,
1164
+ parkedAfter,
1165
+ })
1166
+ if (d.state === ACT.PARKED) {
1167
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
1168
+ return `${base} [UNREACHABLE — @${mem ? mem.name : o.to} is PARKED: silent for ${age}, will not wake on its own]`
1169
+ }
1170
+ if (d.state === ACT.GONE) {
1171
+ return `${base} [UNREACHABLE — @${o.to} is GONE: not connected]`
1172
+ }
1173
+ if (d.state === ACT.PAUSED) {
1174
+ return `${base} [PAUSED by human — do not reassign]`
1175
+ }
1176
+ return base
1177
+ }
1178
+
921
1179
  if (args.all) {
922
- const b = OB.board(obMap)
923
- if (!b.open) return text('Nothing outstanding in this room.')
924
- return text(`${b.open} open, ${b.stale} stale:\n` + b.lines.map((l) => ' ' + l).join('\n'))
1180
+ const openList = OB.openList(obMap, () => now)
1181
+ if (!openList.length) return text('Nothing outstanding in this room.')
1182
+ const staleCount = openList.filter(OB.isStale).length
1183
+ return text(`${openList.length} open, ${staleCount} stale:\n` + openList.map((o) => ' ' + describeDebt(o)).join('\n'))
925
1184
  }
926
- const mine = OB.owedBy(obMap, s2.name), theirs = OB.owedTo(obMap, s2.name)
1185
+ const mine = OB.owedBy(obMap, s2.name, () => now), theirs = OB.owedTo(obMap, s2.name, () => now)
927
1186
  if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
928
1187
  const parts = []
929
- if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + OB.describe(o)).join('\n'))
930
- if (theirs.length) parts.push(`YOU ARE OWED ${theirs.length} — close with hive_answered once you actually have what you needed:\n` + theirs.map((o) => ' ' + OB.describe(o)).join('\n'))
1188
+ if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + describeDebt(o)).join('\n'))
1189
+ if (theirs.length) parts.push(`YOU ARE OWED ${theirs.length} — close with hive_answered once you actually have what you needed:\n` + theirs.map((o) => ' ' + describeDebt(o)).join('\n'))
931
1190
  return text(parts.join('\n\n'))
932
1191
  }
933
1192
  case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
@@ -996,7 +1255,39 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
996
1255
  // tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
997
1256
  // behind a lock it does not hold. This waits for the relay to serialise it.
998
1257
  const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
999
- if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
1258
+ if (got) {
1259
+ // HIV-P18. "It's yours" was all this said, and for a file outside the working set that
1260
+ // was the entire defect: the agent reads its directory, sees nothing, concludes the file
1261
+ // does not exist, and CREATES it -- which is the blind recreate. Measured 21 Sep 2026:
1262
+ // claim returned true, the file doc was never opened, no bytes arrived, and the agent
1263
+ // was told it held something it could not see.
1264
+ //
1265
+ // sync.js now fetches on claim. This reports WHICH of three states the agent is in,
1266
+ // because they demand different next actions and an empty directory cannot tell them
1267
+ // apart. Awaited here rather than in claim(), whose contract is synchronous.
1268
+ let fetched = null
1269
+ try { fetched = await requireSession().hive.fetch(region) } catch { }
1270
+ const head = `claimed ${region} — it's yours.`
1271
+ 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.`)
1277
+ }
1278
+ if (fetched && fetched.ok === false) {
1279
+ return text(`${head}
1280
+
1281
+ !! ${region} IS IN THE ROOM BUT ITS CONTENT HAS NOT ARRIVED. ${fetched.reason || ''}
1282
+ DO NOT create it from scratch — the room's copy exists and writing over it unions two whole files. Retry, or check hive_status (filesHeld of roomFiles).
1283
+
1284
+ Then call hive_release.`)
1285
+ }
1286
+ const note = fetched && fetched.ok && !fetched.alreadyLocal && !fetched.empty
1287
+ ? ` Its content was not in this folder and has been fetched (${fetched.bytes} bytes) — read it before editing.`
1288
+ : ''
1289
+ return text(`claimed ${region} — it's yours.${note} Edit it, then call hive_release.`)
1290
+ }
1000
1291
  const c = requireSession().hive.senseClaim(region)
1001
1292
  return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
1002
1293
  }
@@ -1048,7 +1339,40 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1048
1339
  const dropped = (r.coverage || []).filter((c) => !c.covered)
1049
1340
  return text(`✓ resolution LANDED in ${file} — validated by ICR (parses, no dangling refs), signed by you, synced to every peer.${dropped.length ? ` ⚠ Intent coverage: ${dropped.map((c) => `${c.name}'s changes (${c.missing.slice(0, 5).join(', ')}) did not survive`).join('; ')} — if that was not deliberate, submit a better reconciliation.` : ' Full intent coverage: every side\'s changes survived.'}`)
1050
1341
  }
1051
- case 'hive_members': { const now = Date.now(); return text(requireSession().hive.members().map((m) => `${m.name} (${m.kind})${m.editing && now - m.editing.at < 15000 ? ` — editing ${m.editing.file}` : ''}`).join('\n') || '(none)') }
1342
+ case 'hive_members': {
1343
+ const now = Date.now()
1344
+ const s = requireSession()
1345
+ const act = activityMap()
1346
+ const controls = s.hive.doc.getMap('controls')
1347
+ const parkedAfter = ACT.parkedAfterMs()
1348
+ const present = s.hive.members()
1349
+ const presentKeys = new Set(present.map((m) => String(m.name).toLowerCase()))
1350
+
1351
+ const rows = present.map((m) => {
1352
+ const rec = act ? act.get(String(m.name).toLowerCase()) : null
1353
+ const c = controls.get(m.name)
1354
+ const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
1355
+ return ACT.line(`${m.name} (${m.kind})`, rec, {
1356
+ present: true, passive: !!m.passive,
1357
+ paused: !!(c && c.state === 'paused'),
1358
+ // HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
1359
+ // send it now will sit unread until a human prompts it` about the human.
1360
+ human: String(m.kind || '').toLowerCase() === 'human',
1361
+ now, parkedAfter,
1362
+ }) + editing
1363
+ })
1364
+
1365
+ // HIV-P11. THE ONES THAT ARE NOT HERE ANY MORE, which is the half a human coming back in
1366
+ // the morning actually needs. Awareness is ephemeral: an agent that died at 23:00 simply
1367
+ // vanishes from members(), so the old output could not distinguish "never joined" from
1368
+ // "worked here for three hours and stopped". The activity record is durable, so it can.
1369
+ const goneRows = ACT.all(act)
1370
+ .filter((r) => !presentKeys.has(r.key))
1371
+ .map((r) => ACT.line(r.name || r.key, r, { present: false, now, parkedAfter }))
1372
+
1373
+ const body = [...rows, ...(goneRows.length ? ['', '— not connected —', ...goneRows] : [])]
1374
+ return text(body.join('\n') || '(none)')
1375
+ }
1052
1376
  case 'hive_status': {
1053
1377
  const s = requireSession()
1054
1378
  // Connection state belongs here too, not only in hive_wait: this is the tool an
@@ -1056,17 +1380,36 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1056
1380
  // is almost always "you are still connected, do nothing".
1057
1381
  let conn = null
1058
1382
  try { conn = s.hive.status() } catch { }
1383
+ // `privacy` sits next to `connected` on purpose. These are the two questions an
1384
+ // agent asks here, and only one of them had an answer: "am I reaching anyone" was
1385
+ // reportable and "where is my source going" was not. A bare relay URL is not an
1386
+ // 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 })
1059
1388
  return text({
1060
- room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
1389
+ room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
1390
+ privacy: pv.headline,
1391
+ ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1392
+ ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1061
1393
  unread: mailbox().peek(chatArray()).unreadCount,
1062
1394
  connected: conn ? conn.connected : null,
1063
1395
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
1064
1396
  })
1065
1397
  }
1066
- case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1398
+ // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1399
+ // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1400
+ // 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') }
1067
1402
  default: return err(`unknown tool: ${name}`)
1068
1403
  }
1069
1404
  } catch (e) { return err(`error: ${e.message}`) }
1405
+ finally {
1406
+ // hive_join only: the session does not exist until the case has run, so this is the single
1407
+ // tool that cannot be stamped on the way in. Everything else is stamped above, before the
1408
+ // switch, so that an agent's own record reflects the call it is currently making.
1409
+ try {
1410
+ if (session && name === 'hive_join') ACT.noteAction(activityMap(), { name: session.name, tool: name })
1411
+ } catch { /* a liveness stamp must never be able to fail a tool call */ }
1412
+ }
1070
1413
  }
1071
1414
 
1072
1415
  // MAIL PIGGYBACK — the delivery channel that cannot be ignored.
@@ -1195,10 +1538,37 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1195
1538
 
1196
1539
  // Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
1197
1540
  // hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
1198
- const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
1199
- if (isEntryPoint) {
1541
+ //
1542
+ // COMPARED AS REAL PATHS. On macOS and Linux, npx starts a package's bin through the symlink in
1543
+ // node_modules/.bin. Node resolves that symlink for import.meta.url and NOT for argv[1], so the
1544
+ // plain comparison this used to make was false there: the server never started, the process
1545
+ // exited 0 with no output, and the agent app showed nothing but "failed to start". Reproduced on
1546
+ // 25 Sep 2026 through a Windows junction, which Node treats the same way.
1547
+ function isMainModule() {
1548
+ if (!process.argv[1]) return false
1549
+ const me = fileURLToPath(import.meta.url)
1550
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
1551
+ }
1552
+ const isEntryPoint = isMainModule()
1553
+ if (isEntryPoint && process.argv[2] === 'setup') {
1554
+ // `npx -y hivecode-mcp@latest setup` -- configures every agent app on this machine and checks
1555
+ // each one starts. It never opens stdio: nobody is speaking JSON-RPC to a setup run.
1556
+ const { main } = await import('./hive-setup.js')
1557
+ process.exit(await main(process.argv.slice(3)))
1558
+ } else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
1559
+ console.log(VERSION)
1560
+ process.exit(0)
1561
+ } else if (isEntryPoint) {
1200
1562
  const transport = new StdioServerTransport()
1563
+ // STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
1564
+ // room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
1565
+ // and a presence entry for a member that was gone, until something killed it.
1566
+ server.onclose = () => {
1567
+ try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
1568
+ setTimeout(() => process.exit(0), 500)
1569
+ }
1201
1570
  await server.connect(transport)
1571
+ startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
1202
1572
  // stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
1203
1573
  console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
1204
1574
  }
@@ -1206,6 +1576,7 @@ if (isEntryPoint) {
1206
1576
  /** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
1207
1577
  export const _test = {
1208
1578
  waitForWork,
1579
+ defaultJoinTarget,
1209
1580
  mailbox,
1210
1581
  // Exposed so the throttle's saving is asserted against the counter itself rather than inferred
1211
1582
  // from what happened not to appear in a tool result. Counting absences is how you prove a thing