hivecode-mcp 0.11.6 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hive-mcp.js CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Register it with an MCP client (e.g. Claude Code / Claude Desktop):
6
6
  // command: node args: ["<abs-path>/hive-mcp.js"]
7
- // env (optional): HIVE_RELAY = wss://your-relay (defaults to the hosted relay)
7
+ // env (optional): HIVE_RELAY = wss://your-relay (defaults to PUBLIC_DEFAULT_RELAY in hive-privacy.js)
8
8
  //
9
9
  // Tools exposed:
10
10
  // hive_join join/host a room for a folder (auto kind:ai) -> returns the rules
@@ -34,14 +34,17 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
34
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
35
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
36
  import { startSync, parseLink } from './sync.js'
37
+ import { checkRelay, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
37
38
  import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
38
39
  import { INSTRUCTIONS } from './hive-instructions.js'
39
40
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
41
+ import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
42
+ import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
40
43
  import * as OB from './hive-obligations.js'
41
44
  import * as RC from './hive-receipts.js'
42
45
  import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
43
46
 
44
- const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
47
+ const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
45
48
  let session = null // { hive, room, relay, dir, name, mailbox }
46
49
 
47
50
  // ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
@@ -102,7 +105,7 @@ async function tryResumeSession() {
102
105
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
103
106
  }
104
107
 
105
- async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
108
+ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
106
109
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
107
110
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
108
111
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -118,6 +121,24 @@ This is refused rather than fixed for you because renaming silently is worse: if
118
121
  const ROOT = path.resolve(dir)
119
122
  fs.mkdirSync(ROOT, { recursive: true })
120
123
  const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
124
+ // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
125
+ // .hive.json write below and before startSync opens a socket. A refused join must leave
126
+ // nothing behind -- no config pointing at the wrong relay for the next process to find,
127
+ // and above all no published file. Checking after connecting would report a leak instead
128
+ // of preventing one, which is the difference between a guard and a log line.
129
+ const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
130
+ if (!priv.ok) {
131
+ return `${priv.headline}
132
+
133
+ ${priv.detail}
134
+
135
+ room: ${room}
136
+ mode: ${mode}
137
+ folder: ${ROOT}
138
+
139
+ NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
140
+ was not written. Fix the relay and call hive_join again.`
141
+ }
121
142
  // token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
122
143
  const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
123
144
  // persist the token too, so a re-join from .hive.json keeps the grant (the
@@ -133,7 +154,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
133
154
  // folder is precisely the situation the lock exists to prevent.
134
155
  let hive
135
156
  try {
136
- hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
157
+ hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
137
158
  } catch (e) {
138
159
  if (e && e.code === 'EHIVELOCKED') {
139
160
  return text(
@@ -143,7 +164,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
143
164
  }
144
165
  throw e
145
166
  }
146
- session = { hive, room, relay, dir, name, owner, mailbox: null }
167
+ session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
147
168
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
148
169
  // Wait for the first sync so members/board/rules are ready.
149
170
  //
@@ -156,6 +177,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
156
177
  // here, at the moment we know whether the socket came up.
157
178
  let synced = false
158
179
  let connErr = ''
180
+ let refusedCode = null
159
181
  await new Promise((resolve) => {
160
182
  let done = false
161
183
  const finish = () => { if (!done) { done = true; resolve() } }
@@ -163,13 +185,35 @@ This is refused rather than fixed for you because renaming silently is worse: if
163
185
  // A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
164
186
  // Q-code and destroys the socket, so this event is the only place the reason is visible.
165
187
  hive.provider.on('connection-error', (ev) => {
166
- const code = ev && (ev.code || ev.status || ev.type)
188
+ const code = RH.statusFromError(ev) || (ev && (ev.code || ev.status || ev.type))
189
+ if (code && typeof code === 'number') refusedCode = code
167
190
  if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
168
191
  })
169
192
  setTimeout(finish, 8000) // don't hang if the relay is cold
170
193
  })
171
194
  if (!synced) {
195
+ // HIV-P12. EVIDENCE BEFORE INFERENCE.
196
+ // If the relay actually refused us with an HTTP status, explainStatus() maps it to the
197
+ // authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
198
+ // Only when nothing refused us with a code do we query /health to infer whether it was capacity.
199
+ if (refusedCode) {
200
+ const explanation = RH.explainStatus(refusedCode, relay)
201
+ return [
202
+ `NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
203
+ `room: ${room}`,
204
+ `relay: ${relay}`,
205
+ `folder: ${ROOT}`,
206
+ '',
207
+ 'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
208
+ 'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
209
+ 'you do now is invisible to the team and can be silently overwritten later.',
210
+ '',
211
+ 'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
212
+ 'it reports `connected` and `filesSynced`, and both are authoritative.',
213
+ ].join('\n')
214
+ }
172
215
  const why = connErr || 'no sync within 8s -- the relay did not answer'
216
+ const diag = await RH.explainNotSyncing(relay, { what: 'join' })
173
217
  return [
174
218
  `NOT JOINED -- ${why}.`,
175
219
  `room: ${room}`,
@@ -180,10 +224,9 @@ This is refused rather than fixed for you because renaming silently is worse: if
180
224
  'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
181
225
  'you do now is invisible to the team and can be silently overwritten later.',
182
226
  '',
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.',
227
+ diag.text,
228
+ '',
229
+ 'Two causes the relay cannot tell you about, worth checking if the above says capacity is fine:',
187
230
  ' - the relay is not running, or the port/room in the link is wrong.',
188
231
  ' - the room is secured and this link carries no token (Q4xx on connect).',
189
232
  '',
@@ -283,7 +326,12 @@ This is refused rather than fixed for you because renaming silently is worse: if
283
326
  // The invite link carries the token when there is one — without it a secured room rejects
284
327
  // whoever you hand it to, and the Control Room says the link is missing its token.
285
328
  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}`
329
+ // THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
330
+ // who has not run a relay -- the public one and a company's own look identical. The
331
+ // bracketed verdict is what makes "did my source just leave the building?" answerable
332
+ // without asking, and the warning line below is what makes it actionable.
333
+ const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
334
+ return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
287
335
  }
288
336
 
289
337
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
@@ -298,6 +346,10 @@ function mailbox() {
298
346
  return s.mailbox
299
347
  }
300
348
  function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
349
+
350
+ // HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
351
+ // observation about a tool call, not part of it, and it must never be the reason a tool fails.
352
+ function activityMap() { try { return requireSession().hive.doc.getMap('activity') } catch { return null } }
301
353
  // Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
302
354
  // keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
303
355
  function findBusy(map, name) {
@@ -421,7 +473,7 @@ function waitForWork(timeoutMs, opts = {}) {
421
473
 
422
474
  const TOOLS = [
423
475
  { name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
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.' } } } },
476
+ inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
425
477
  { name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
426
478
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
427
479
  { 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 +638,8 @@ function enforceDcoLock(opts = {}) {
586
638
  // itself reporting the wrong build. Read the truth instead; package.json always ships in the
587
639
  // tarball and sits beside this file in both the repo and the published package.
588
640
  // 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
- })()
641
+ import { VERSION } from "./hive-version.js"
642
+ export { VERSION }
596
643
  // `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
597
644
  // makes it the only channel that reaches an agent before its first tool call. Everything else we
598
645
  // have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
@@ -611,6 +658,21 @@ async function dispatch(req) {
611
658
  // persisted session exists for this cwd, transparently rejoin before any tool runs —
612
659
  // the agent (and its human) never see a "not in a room" flake again.
613
660
  if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
661
+ // HIV-P11. STAMPED BEFORE THE TOOL RUNS, not after it returns.
662
+ //
663
+ // The first version stamped in a `finally` -- which is wrong by exactly one call, and the
664
+ // suite caught it: an agent that called hive_members saw ITSELF reported PARKED, because the
665
+ // record it read still held the timestamp of its previous call. The activity is the call
666
+ // BEGINNING; a tool that takes four minutes is working for all four of them, and a tool that
667
+ // throws was still an agent doing something. Stamping first covers both, and covers every
668
+ // tool anyone adds later, because there is no path into the switch that skips this line.
669
+ //
670
+ // hive_wait stamps its own entry and exit (waitingSince must mean "inside the call RIGHT
671
+ // NOW"). hive_leave drops the row instead of refreshing it. hive_join has no session yet, so
672
+ // it is stamped in the finally below -- the one case where after is the only option.
673
+ if (session && name !== 'hive_wait' && name !== 'hive_leave') {
674
+ try { ACT.noteAction(activityMap(), { name: session.name, tool: name }) } catch { }
675
+ }
614
676
  switch (name) {
615
677
  case 'hive_join': return text(await joinRoom(args))
616
678
  case 'hive_say': {
@@ -643,58 +705,137 @@ async function dispatch(req) {
643
705
  // reply: it arrives before the sender starts waiting, and it costs the room no message.
644
706
  // This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
645
707
  const s = requireSession()
646
- const present = new Set(s.hive.members().map((m) => String(m.name).toLowerCase()))
708
+ const members = s.hive.members()
709
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
710
+ const present = new Set(members.map((m) => String(m.name).toLowerCase()))
647
711
  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)
712
+ const act = activityMap()
713
+ const controls = s.hive.doc.getMap('controls')
714
+ const parkedAfter = ACT.parkedAfterMs()
715
+ const now = Date.now()
716
+
717
+ const everHere = new Set(present)
718
+ for (const m of s.hive.doc.getArray('chat').toArray()) {
719
+ if (m && m.by) everHere.add(String(m.by).toLowerCase())
720
+ }
721
+
722
+ // HIV-P11b. Send-time inspection of recipient state:
723
+ // Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
724
+ function describeRecipientLiveness(n) {
725
+ const lc = n.toLowerCase()
726
+ const mem = memberMap.get(lc)
727
+ const isPresent = !!mem
728
+ const rec = act ? act.get(lc) : null
729
+ const c = controls.get(mem ? mem.name : n)
730
+ const isPaused = !!(c && c.state === 'paused')
731
+ const isPassive = !!(mem && mem.passive)
732
+ const d = ACT.describe(rec, {
733
+ present: isPresent,
734
+ paused: isPaused,
735
+ passive: isPassive,
736
+ now,
737
+ parkedAfter,
738
+ })
739
+ const displayName = mem ? mem.name : n
740
+
741
+ if (d.state === ACT.PARKED) {
742
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
743
+ const tool = d.lastTool ? ` (${d.lastTool})` : ''
744
+ return `⚠ @${displayName} — PARKED: connected, but NOT inside hive_wait and silent for ${age}${tool}. ` +
745
+ `It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
746
+ }
747
+ if (d.state === ACT.PAUSED) {
748
+ return `⚠ @${displayName} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
749
+ }
750
+ if (d.state === ACT.PASSIVE) {
751
+ return `⚠ @${displayName} — PASSIVE LISTENER. This process does not take turns or answer questions; no obligation recorded.`
752
+ }
753
+ if (d.state === ACT.GONE) {
754
+ if (everHere.has(lc)) {
755
+ return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
756
+ } else {
757
+ return `⚠ @${displayName} — NOT IN THIS ROOM. Nobody by that name is here, so nobody is going to answer. Call hive_members and re-address it now; this is the one failure mode that otherwise costs you a whole shift.`
758
+ }
759
+ }
760
+ // d.state is WORKING or WAITING
761
+ const busy = findBusy(busyMap, n)
762
+ if (busy) {
763
+ return describeRecipient(displayName, busy, true, now)
764
+ }
765
+ return ''
766
+ }
767
+
768
+ const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
769
+ const seenCandidates = new Set()
770
+ const uniqueCandidates = []
771
+ for (const n of candidateNames) {
772
+ const lc = n.toLowerCase()
773
+ if (!seenCandidates.has(lc)) {
774
+ seenCandidates.add(lc)
775
+ uniqueCandidates.push(n)
776
+ }
777
+ }
778
+ const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
652
779
  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.
780
+ // NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
781
+ // HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
782
+ // 1. Absent names: warned immediately, ZERO obligations opened.
783
+ // 2. Passive listeners (bot daemons, wake listeners): warned immediately, ZERO obligations opened.
784
+ // 3. Active participants: real debt opened, dischargeable ONLY by the asker.
785
+ // 4. @all broadcast: targets only active non-passive AI agents in the room.
659
786
  let obStr = ''
660
787
  if (args.needsReply) {
661
788
  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
789
  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())
790
+
791
+ let activeTargets = []
792
+ let awayTargets = []
793
+ let neverSeenNames = []
794
+ let passiveNames = []
795
+
796
+ if (mentionsAll(body)) {
797
+ activeTargets = members
798
+ .filter((m) => String(m.name).toLowerCase() !== s.name.toLowerCase() && !m.passive && m.kind === 'ai')
799
+ .map((m) => String(m.name))
800
+ } else {
801
+ for (const n of uniqueCandidates) {
802
+ const lc = n.toLowerCase()
803
+ const mem = memberMap.get(lc)
804
+ if (mem) {
805
+ if (mem.passive) {
806
+ passiveNames.push(mem.name || n)
807
+ } else {
808
+ activeTargets.push(mem.name || n)
809
+ }
810
+ } else {
811
+ if (everHere.has(lc)) {
812
+ awayTargets.push(n)
813
+ } else {
814
+ neverSeenNames.push(n)
815
+ }
816
+ }
817
+ }
681
818
  }
682
- const opened = targets.map((n) => OB.open(obMap, {
819
+
820
+ const openedActive = activeTargets.map((n) => OB.open(obMap, {
683
821
  from: s.name, to: n, text: body, dueMs,
684
- absent: !present.has(n.toLowerCase()),
685
- neverSeen: !everHere.has(n.toLowerCase()),
822
+ absent: false,
823
+ neverSeen: false,
686
824
  }))
825
+
826
+ const openedAway = awayTargets.map((n) => OB.open(obMap, {
827
+ from: s.name, to: n, text: body, dueMs,
828
+ absent: true,
829
+ neverSeen: false,
830
+ }))
831
+
832
+ const opened = [...openedActive, ...openedAway]
833
+
687
834
  if (opened.length) {
688
- obStr = `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
835
+ obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
689
836
  `This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
690
837
  `a reply does not close it either — you decide whether you were actually answered. If it ` +
691
838
  `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
839
  }
699
840
  }
700
841
  return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
@@ -799,7 +940,21 @@ async function dispatch(req) {
799
940
  }
800
941
  case 'hive_wait': {
801
942
  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 })
943
+ // HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
944
+ // point", and the finally is what keeps that true. A wait that returns or throws while
945
+ // leaving the flag set describes an agent parked in a call it has already left -- and
946
+ // describes it as WAITING, the single most reassuring state in the taxonomy. stateOf()
947
+ // gates WAITING on awareness so a process that DIES here cannot fake it, but a live
948
+ // process that merely threw is still present, so the gate does not cover that and this
949
+ // does. The name is captured first because the session can be gone by the finally.
950
+ const waitName = requireSession().name
951
+ ACT.noteWaitStart(activityMap(), { name: waitName })
952
+ let r
953
+ try {
954
+ r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
955
+ } finally {
956
+ try { ACT.noteWaitEnd(activityMap(), { name: waitName }) } catch { }
957
+ }
803
958
  const globalState = requireSession().hive.getState()
804
959
  // THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
805
960
  // sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
@@ -918,16 +1073,52 @@ async function dispatch(req) {
918
1073
  case 'hive_owed': {
919
1074
  const s2 = requireSession()
920
1075
  const obMap = s2.hive.doc.getMap('obligations')
1076
+ const act = activityMap()
1077
+ const parkedAfter = ACT.parkedAfterMs()
1078
+ const members = s2.hive.members()
1079
+ const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
1080
+ const controls = s2.hive.doc.getMap('controls')
1081
+ const now = Date.now()
1082
+
1083
+ // HIV-P11b. Dynamic reachability annotation for debts:
1084
+ // Distinguishes actionable pending debts from debts owed by PARKED, GONE, or PAUSED agents.
1085
+ function describeDebt(o) {
1086
+ const base = OB.describe(o, () => now)
1087
+ const lc = String(o.to || '').toLowerCase()
1088
+ const mem = memberMap.get(lc)
1089
+ const rec = act ? act.get(lc) : null
1090
+ const c = controls.get(mem ? mem.name : o.to)
1091
+ const d = ACT.describe(rec, {
1092
+ present: !!mem,
1093
+ paused: !!(c && c.state === 'paused'),
1094
+ passive: !!(mem && mem.passive),
1095
+ now,
1096
+ parkedAfter,
1097
+ })
1098
+ if (d.state === ACT.PARKED) {
1099
+ const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
1100
+ return `${base} [UNREACHABLE — @${mem ? mem.name : o.to} is PARKED: silent for ${age}, will not wake on its own]`
1101
+ }
1102
+ if (d.state === ACT.GONE) {
1103
+ return `${base} [UNREACHABLE — @${o.to} is GONE: not connected]`
1104
+ }
1105
+ if (d.state === ACT.PAUSED) {
1106
+ return `${base} [PAUSED by human — do not reassign]`
1107
+ }
1108
+ return base
1109
+ }
1110
+
921
1111
  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'))
1112
+ const openList = OB.openList(obMap, () => now)
1113
+ if (!openList.length) return text('Nothing outstanding in this room.')
1114
+ const staleCount = openList.filter(OB.isStale).length
1115
+ return text(`${openList.length} open, ${staleCount} stale:\n` + openList.map((o) => ' ' + describeDebt(o)).join('\n'))
925
1116
  }
926
- const mine = OB.owedBy(obMap, s2.name), theirs = OB.owedTo(obMap, s2.name)
1117
+ const mine = OB.owedBy(obMap, s2.name, () => now), theirs = OB.owedTo(obMap, s2.name, () => now)
927
1118
  if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
928
1119
  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'))
1120
+ if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + describeDebt(o)).join('\n'))
1121
+ if (theirs.length) parts.push(`YOU ARE OWED ${theirs.length} — close with hive_answered once you actually have what you needed:\n` + theirs.map((o) => ' ' + describeDebt(o)).join('\n'))
931
1122
  return text(parts.join('\n\n'))
932
1123
  }
933
1124
  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 +1187,39 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
996
1187
  // tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
997
1188
  // behind a lock it does not hold. This waits for the relay to serialise it.
998
1189
  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.`)
1190
+ if (got) {
1191
+ // HIV-P18. "It's yours" was all this said, and for a file outside the working set that
1192
+ // was the entire defect: the agent reads its directory, sees nothing, concludes the file
1193
+ // does not exist, and CREATES it -- which is the blind recreate. Measured 21 Sep 2026:
1194
+ // claim returned true, the file doc was never opened, no bytes arrived, and the agent
1195
+ // was told it held something it could not see.
1196
+ //
1197
+ // sync.js now fetches on claim. This reports WHICH of three states the agent is in,
1198
+ // because they demand different next actions and an empty directory cannot tell them
1199
+ // apart. Awaited here rather than in claim(), whose contract is synchronous.
1200
+ let fetched = null
1201
+ try { fetched = await requireSession().hive.fetch(region) } catch { }
1202
+ const head = `claimed ${region} — it's yours.`
1203
+ if (fetched && fetched.notInRoom) {
1204
+ return text(`${head}
1205
+
1206
+ !! ${region} IS NOT IN THIS ROOM. No client has ever published it, so nothing will fetch it and it is NOT simply outside your working set. If you create it now you are creating a new file, not editing an existing one — which is correct only if that is what you intend. If you expected content here, the path is probably outside the room's sync scope: check that first, then ask whoever holds the file to write it into their synced folder.
1207
+
1208
+ Edit it, then call hive_release.`)
1209
+ }
1210
+ if (fetched && fetched.ok === false) {
1211
+ return text(`${head}
1212
+
1213
+ !! ${region} IS IN THE ROOM BUT ITS CONTENT HAS NOT ARRIVED. ${fetched.reason || ''}
1214
+ DO NOT create it from scratch — the room's copy exists and writing over it unions two whole files. Retry, or check hive_status (filesHeld of roomFiles).
1215
+
1216
+ Then call hive_release.`)
1217
+ }
1218
+ const note = fetched && fetched.ok && !fetched.alreadyLocal && !fetched.empty
1219
+ ? ` Its content was not in this folder and has been fetched (${fetched.bytes} bytes) — read it before editing.`
1220
+ : ''
1221
+ return text(`claimed ${region} — it's yours.${note} Edit it, then call hive_release.`)
1222
+ }
1000
1223
  const c = requireSession().hive.senseClaim(region)
1001
1224
  return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
1002
1225
  }
@@ -1048,7 +1271,40 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1048
1271
  const dropped = (r.coverage || []).filter((c) => !c.covered)
1049
1272
  return text(`✓ resolution LANDED in ${file} — validated by ICR (parses, no dangling refs), signed by you, synced to every peer.${dropped.length ? ` ⚠ Intent coverage: ${dropped.map((c) => `${c.name}'s changes (${c.missing.slice(0, 5).join(', ')}) did not survive`).join('; ')} — if that was not deliberate, submit a better reconciliation.` : ' Full intent coverage: every side\'s changes survived.'}`)
1050
1273
  }
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)') }
1274
+ case 'hive_members': {
1275
+ const now = Date.now()
1276
+ const s = requireSession()
1277
+ const act = activityMap()
1278
+ const controls = s.hive.doc.getMap('controls')
1279
+ const parkedAfter = ACT.parkedAfterMs()
1280
+ const present = s.hive.members()
1281
+ const presentKeys = new Set(present.map((m) => String(m.name).toLowerCase()))
1282
+
1283
+ const rows = present.map((m) => {
1284
+ const rec = act ? act.get(String(m.name).toLowerCase()) : null
1285
+ const c = controls.get(m.name)
1286
+ const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
1287
+ return ACT.line(`${m.name} (${m.kind})`, rec, {
1288
+ present: true, passive: !!m.passive,
1289
+ paused: !!(c && c.state === 'paused'),
1290
+ // HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
1291
+ // send it now will sit unread until a human prompts it` about the human.
1292
+ human: String(m.kind || '').toLowerCase() === 'human',
1293
+ now, parkedAfter,
1294
+ }) + editing
1295
+ })
1296
+
1297
+ // HIV-P11. THE ONES THAT ARE NOT HERE ANY MORE, which is the half a human coming back in
1298
+ // the morning actually needs. Awareness is ephemeral: an agent that died at 23:00 simply
1299
+ // vanishes from members(), so the old output could not distinguish "never joined" from
1300
+ // "worked here for three hours and stopped". The activity record is durable, so it can.
1301
+ const goneRows = ACT.all(act)
1302
+ .filter((r) => !presentKeys.has(r.key))
1303
+ .map((r) => ACT.line(r.name || r.key, r, { present: false, now, parkedAfter }))
1304
+
1305
+ const body = [...rows, ...(goneRows.length ? ['', '— not connected —', ...goneRows] : [])]
1306
+ return text(body.join('\n') || '(none)')
1307
+ }
1052
1308
  case 'hive_status': {
1053
1309
  const s = requireSession()
1054
1310
  // Connection state belongs here too, not only in hive_wait: this is the tool an
@@ -1056,17 +1312,36 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1056
1312
  // is almost always "you are still connected, do nothing".
1057
1313
  let conn = null
1058
1314
  try { conn = s.hive.status() } catch { }
1315
+ // `privacy` sits next to `connected` on purpose. These are the two questions an
1316
+ // agent asks here, and only one of them had an answer: "am I reaching anyone" was
1317
+ // reportable and "where is my source going" was not. A bare relay URL is not an
1318
+ // answer to the second -- the public relay and a company's own are the same shape.
1319
+ const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
1059
1320
  return text({
1060
1321
  room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
1322
+ privacy: pv.headline,
1323
+ ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1324
+ ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
1061
1325
  unread: mailbox().peek(chatArray()).unreadCount,
1062
1326
  connected: conn ? conn.connected : null,
1063
1327
  filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
1064
1328
  })
1065
1329
  }
1066
- case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1330
+ // HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
1331
+ // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1332
+ // and diluting it with agents that signed off correctly is how a signal stops being read.
1333
+ case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
1067
1334
  default: return err(`unknown tool: ${name}`)
1068
1335
  }
1069
1336
  } catch (e) { return err(`error: ${e.message}`) }
1337
+ finally {
1338
+ // hive_join only: the session does not exist until the case has run, so this is the single
1339
+ // tool that cannot be stamped on the way in. Everything else is stamped above, before the
1340
+ // switch, so that an agent's own record reflects the call it is currently making.
1341
+ try {
1342
+ if (session && name === 'hive_join') ACT.noteAction(activityMap(), { name: session.name, tool: name })
1343
+ } catch { /* a liveness stamp must never be able to fail a tool call */ }
1344
+ }
1070
1345
  }
1071
1346
 
1072
1347
  // MAIL PIGGYBACK — the delivery channel that cannot be ignored.