hivecode-mcp 0.11.1 → 0.11.3

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.
@@ -0,0 +1,41 @@
1
+ # HIVE RULES — read this first. Everyone in this room (human or AI) follows these.
2
+
3
+ You are in a Hivecode room: humans and AI agents edit ONE project together live.
4
+ These rules keep anyone from destroying another's work. The sync layer enforces
5
+ the hard parts automatically; you do the rest.
6
+
7
+ ## Before you touch a file
8
+ 1. Read HIVE_CHAT.md — what is everyone doing right now.
9
+ 2. Read HIVE_BOARD.md — which files were just rewritten.
10
+ 3. If a file you plan to edit appears there, RE-READ it before changing it.
11
+
12
+ ## While you work
13
+ 4. ANNOUNCE in chat what you are taking before you start.
14
+ 5. PREFER SMALL PATCHES — grep to the spot, edit a few lines (they merge cleanly).
15
+ 6. AVOID full-file rewrites; if you must, RE-READ first (rewrites are auto-logged).
16
+ 7. STAY IN YOUR LANE — read others' areas, but leave edits to the owner.
17
+
18
+ ## When things collide
19
+ 8. <<<<<<< ======= >>>>>>> markers = a real conflict: keep the right code, delete
20
+ the markers. Never ignore or blindly overwrite.
21
+ 9. If your edit was merged/reworked, that is normal — re-read and continue.
22
+
23
+ ## Talking
24
+ 10. Coordinate in chat. ASK before anything destructive in another's area.
25
+
26
+ ## Mission control
27
+ - If a human PAUSES you, finish your current step and STOP — start no new work
28
+ until resumed. A human may reassign your focus anytime; their directive wins.
29
+
30
+ ## When you get a ping mid-task (interruptions)
31
+ 11. Another AGENT pinging or assigning you = COORDINATION: auto-accepted and
32
+ QUEUED. It does NOT interrupt — finish your current atomic step first (never
33
+ abandon half-done work), THEN handle it. If it's urgent (build broken,
34
+ blocking others), do it now.
35
+ 12. ACKNOWLEDGE the moment you see a ping so the sender isn't left hanging, e.g.
36
+ "got it — finishing X (~2 min), then on your auth.js fix".
37
+ 13. YOU triage: say whether you'll do it now or after your current step. Your
38
+ OWNER can override anytime — if they say "do it now" or "skip that" in chat,
39
+ that wins (owner instructions are always honored).
40
+ 14. A ping from a HUMAN who is not your owner does NOT auto-run — it waits for
41
+ your owner's approval. Keep working; your owner decides.
package/docs/MCP.md ADDED
@@ -0,0 +1,77 @@
1
+ # Hivecode MCP server
2
+
3
+ Lets any MCP-capable AI agent (Claude Code, Claude Desktop, etc.) join a Hivecode
4
+ room and coordinate through **native tool calls** — no scripts to run, no human
5
+ to set it up.
6
+
7
+ ## Register it
8
+
9
+ Add Hivecode to your MCP client config. The command runs this repo's server.
10
+
11
+ **Claude Code** (`.mcp.json` in your project, or user settings):
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "hivecode": {
16
+ "command": "node",
17
+ "args": ["C:/Users/G1/Desktop/N/hive-mcp.js"],
18
+ "env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ `HIVE_RELAY` is optional (defaults to the hosted relay).
25
+
26
+ ### Lower-friction join (no file copy)
27
+
28
+ The package declares a `bin` (`hivecode-mcp` → `hive-mcp.js`), so once it's
29
+ published to npm the other agent needs no local copy/clone — just:
30
+
31
+ ```json
32
+ { "mcpServers": { "hivecode": { "command": "npx", "args": ["-y", "hivecode-mcp"] } } }
33
+ ```
34
+
35
+ `npx` fetches and runs it. (End state, even simpler: a **remote** MCP server
36
+ hosted on the relay → config is just a `url`, zero install. Tradeoff: a remote
37
+ server can't touch the agent's local disk, so file work goes through tools on
38
+ the shared doc; humans keep the extension for local files.)
39
+
40
+ ## Tools the agent gets
41
+
42
+ | Tool | What it does |
43
+ |------|--------------|
44
+ | `hive_join` | Join/host a room for a folder. Auto-joins as an **AI** participant. Returns the room info **and the HIVE_RULES to follow**. |
45
+ | `hive_say` | Post a coordination message — announce what you're about to work on. |
46
+ | `hive_read_chat` | Read the room conversation (read this to coordinate). |
47
+ | `hive_read_board` | Read recent whole-file rewrites (read before editing a file). |
48
+ | `hive_members` | Who's in the room (humans + agents). |
49
+ | `hive_status` | Current session info. |
50
+ | `hive_leave` | Leave the room. |
51
+
52
+ ## How an agent uses it
53
+
54
+ 1. `hive_join` (with a `link`, a `dir` containing `.hive.json`, or nothing to host a new room) — pass `owner` so the right human can approve your tasks.
55
+ 2. Read the returned **HIVE_RULES**.
56
+ 3. `hive_read_chat` + `hive_read_board` before touching files.
57
+ 4. `hive_say` to announce intent, then edit files in the synced folder.
58
+ 5. Keep editing — changes merge with everyone else's safely; rewrites are auto-logged.
59
+
60
+ ## Reacting to directed work (no polling)
61
+
62
+ A human can direct a task at you, but you only act once **your owner approves**.
63
+ Don't poll — **block** on `hive_wait`:
64
+
65
+ ```
66
+ loop:
67
+ result = hive_wait(timeoutSeconds: 60) # returns the instant your owner approves
68
+ if result has APPROVED WORK:
69
+ do it, then hive_complete(id)
70
+ # else just call hive_wait again
71
+ ```
72
+
73
+ `hive_wait` returns within ~1s of the approval (it reacts to the live update),
74
+ and also wakes on new chat so you stay responsive to teammates.
75
+
76
+ This is the lowest-friction way for AIs to adopt the protocol in `SPEC.md`:
77
+ the agent never runs a command, it just calls tools.
package/grm-link.cjs ADDED
@@ -0,0 +1,141 @@
1
+ // Gives a Hivecode room a MEMORY, by handing its history to GRM.
2
+ //
3
+ // A room already records everything worth remembering -- HIVE_CHAT.md is the participants'
4
+ // conversation, HIVE_BOARD.md is what was rewritten and by whom, HIVE_TASKS.md is the
5
+ // directed work and its approvals. All of it is written to the room folder as ordinary
6
+ // markdown. And GRM already indexes named files from `code_roots.txt`, re-read on every
7
+ // watcher pass, so a path added here takes effect without restarting anything.
8
+ //
9
+ // So the two halves need no new format, daemon or protocol between them: name those files,
10
+ // and an agent that joins the room tomorrow can ask what was decided in it last week --
11
+ // including in conversations it was never present for. Neither product can do that alone.
12
+ //
13
+ // CommonJS on purpose. sync.js is ESM and the VS Code extension is CJS; a .cjs file is the
14
+ // one shape both can load, so this exists ONCE. The duplicated reconcile()/HIVE_RULES text
15
+ // across those two files has already drifted twice and cost real bugs -- not repeating it.
16
+
17
+ const fs = require('fs')
18
+ const os = require('os')
19
+ const path = require('path')
20
+ const crypto = require('crypto')
21
+
22
+ // The room's own record. NOT the room folder: `code_roots.txt` carries a warning written
23
+ // after adding one directory appended 90,215 blocks in a single pass and had to be cut back
24
+ // out of the journal -- "a root is a whole repository unless you check". Naming four files
25
+ // gives the decision history and nothing else.
26
+ const HISTORY_FILES = ['HIVE_CHAT.md', 'HIVE_BOARD.md', 'HIVE_TASKS.md', 'HIVE_MEMBERS.md']
27
+
28
+ function grmHome() {
29
+ const env = (process.env.GRM_HOME || '').trim()
30
+ return env || path.join(os.homedir(), '.grm')
31
+ }
32
+
33
+ function rootsPath() {
34
+ return path.join(grmHome(), 'code_roots.txt')
35
+ }
36
+
37
+ // Keyed on the resolved directory so the same room in two folders gets two blocks, and the
38
+ // same folder rejoined a hundred times keeps exactly one.
39
+ function tagFor(dir) {
40
+ return 'hivecode:' + crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 12)
41
+ }
42
+
43
+ // The file is written by GRM's installer with a UTF-8 BOM. GRM's own reader strips it, but
44
+ // this must not *drop* it on the way through -- the file belongs to GRM, and a tool that
45
+ // silently reformats another tool's config is how you get a bug nobody can find.
46
+ function readRoots(p) {
47
+ const raw = fs.readFileSync(p, 'utf8')
48
+ return raw.charCodeAt(0) === 0xfeff ? { bom: '', body: raw.slice(1) } : { bom: '', body: raw }
49
+ }
50
+
51
+ function writeRootsAtomic(p, bom, body) {
52
+ const tmp = p + '.hivecode.tmp'
53
+ fs.writeFileSync(tmp, bom + body, 'utf8')
54
+ fs.renameSync(tmp, p) // the watcher re-reads on every pass; never let it see a half-written file
55
+ }
56
+
57
+ // Drop any block previously written for this room. Returns the body without it.
58
+ function stripBlock(body, tag) {
59
+ const lines = body.split(/\r?\n/)
60
+ const out = []
61
+ let inside = false
62
+ for (const line of lines) {
63
+ if (!inside && line.startsWith('# >>> ' + tag)) { inside = true; continue }
64
+ if (inside) { if (line.startsWith('# <<< ' + tag)) inside = false; continue }
65
+ out.push(line)
66
+ }
67
+ return out.join('\n')
68
+ }
69
+
70
+ function blockFor(dir, tag, files) {
71
+ const head = `# >>> ${tag} Hivecode room at ${path.resolve(dir)}`
72
+ const why = '# Added by Hivecode so this room\'s decisions are in memory. Safe to delete.'
73
+ const tail = `# <<< ${tag}`
74
+ return [head, why, ...files, tail].join('\n')
75
+ }
76
+
77
+ /**
78
+ * Point GRM at this room's history. Idempotent and cheap: it rewrites `code_roots.txt`
79
+ * only when the set of existing history files has actually changed, so it is fine to call
80
+ * on every join and again whenever one of those files is written.
81
+ *
82
+ * Returns { linked, files, reason }. Never throws -- a memory that cannot be wired up is
83
+ * not a reason to fail somebody's join.
84
+ */
85
+ function linkRoom(dir, log = () => {}) {
86
+ try {
87
+ const p = rootsPath()
88
+
89
+ // DO NOT CREATE THIS FILE. GRM's reader has three states, and they are not two:
90
+ // no file at all -> fall back to the default root; a file naming paths that all fail
91
+ // to resolve -> watch NOTHING, deliberately, so a typo is visible rather than buried.
92
+ // Creating one here containing only HIVE_*.md would therefore convert "GRM indexes my
93
+ // projects" into "GRM indexes this room and nothing else" the moment the paths lag
94
+ // behind the folder. Only ever append to a file GRM already owns.
95
+ if (!fs.existsSync(p)) return { linked: false, files: [], reason: 'GRM is not installed on this machine' }
96
+
97
+ const files = HISTORY_FILES
98
+ .map((f) => path.resolve(dir, f))
99
+ .filter((f) => { try { return fs.statSync(f).isFile() } catch { return false } })
100
+ if (!files.length) return { linked: false, files: [], reason: 'room has not written its history yet' }
101
+
102
+ const tag = tagFor(dir)
103
+ const { bom, body } = readRoots(p)
104
+ const stripped = stripBlock(body, tag)
105
+ const next = stripped.replace(/\s*$/, '') + '\n\n' + blockFor(dir, tag, files) + '\n'
106
+
107
+ if (bom + next === bom + body) return { linked: true, files, reason: 'already linked' } // no write, no watcher churn
108
+ writeRootsAtomic(p, bom, next)
109
+ log(`[hive] room history handed to GRM (${files.length} file(s)) -- agents here can now recall what this room decided before they joined.`)
110
+ return { linked: true, files, reason: 'linked' }
111
+ } catch (e) {
112
+ log(`[hive] could not hand this room's history to GRM: ${e && e.message}`)
113
+ return { linked: false, files: [], reason: String((e && e.message) || e) }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remove this room from GRM's roots. Called on leave, so a room you are no longer in stops
119
+ * contributing to what your agents recall.
120
+ */
121
+ function unlinkRoom(dir, log = () => {}) {
122
+ try {
123
+ const p = rootsPath()
124
+ if (!fs.existsSync(p)) return { unlinked: false, reason: 'GRM is not installed on this machine' }
125
+ const tag = tagFor(dir)
126
+ const { bom, body } = readRoots(p)
127
+ if (!body.includes('# >>> ' + tag)) return { unlinked: false, reason: 'was not linked' }
128
+ writeRootsAtomic(p, bom, stripBlock(body, tag).replace(/\s*$/, '') + '\n')
129
+ log('[hive] room history removed from GRM.')
130
+ return { unlinked: true, reason: 'unlinked' }
131
+ } catch (e) {
132
+ return { unlinked: false, reason: String((e && e.message) || e) }
133
+ }
134
+ }
135
+
136
+ /** Whether GRM is present at all -- used to decide whether to mention memory in the UI. */
137
+ function grmPresent() {
138
+ try { return fs.existsSync(rootsPath()) } catch { return false }
139
+ }
140
+
141
+ module.exports = { linkRoom, unlinkRoom, grmPresent, rootsPath, HISTORY_FILES }
package/hive-coord.js CHANGED
@@ -31,8 +31,19 @@
31
31
 
32
32
  export function makeCoordinator(shared, self, opts = {}) {
33
33
  const ttl = opts.ttl != null ? opts.ttl : 5000
34
+ // A holder may RENEW its own claim, because a long edit should not lose the file mid-write.
35
+ // But renewal cannot be unbounded, or the lease stops being a lease. MEASURED on a 12-agent
36
+ // run: one agent re-claimed site/pricing.md eight times and legal/terms.md seven times and
37
+ // released twice, and since `claim` reset `at` on every call the 60s TTL never once elapsed.
38
+ // Six other agents sensed a live owner for the whole run; sixteen messages asked it to let go.
39
+ // `since` records the FIRST acquisition and survives renewal, so what caps a claim is total
40
+ // hold time rather than time since the holder last touched it.
41
+ const maxHold = opts.maxHold != null ? opts.maxHold : ttl * 3
34
42
  const now = opts.now || (() => Date.now())
35
- const expired = (c) => !c || (now() - c.at) >= c.ttl
43
+ const held = (c) => now() - (c.since != null ? c.since : c.at) // total, across renewals
44
+ const cap = (c) => (c.maxHold != null ? c.maxHold : maxHold)
45
+ const overheld = (c) => !!c && held(c) >= cap(c)
46
+ const expired = (c) => !c || (now() - c.at) >= c.ttl || overheld(c)
36
47
 
37
48
  // SENSE: the live, non-expired claim on a region (or null if open).
38
49
  const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
@@ -40,7 +51,16 @@ export function makeCoordinator(shared, self, opts = {}) {
40
51
  // Is this region open to ME? (free, expired, or already mine)
41
52
  const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
42
53
 
43
- const claim = (region, intent) => { shared.set(region, { by: self, intent, at: now(), ttl }) }
54
+ // A renewal keeps the original `since`; a fresh acquisition starts a new one. Claims written
55
+ // before `since` existed fall back to `at`, so an older room stays readable.
56
+ const claim = (region, intent) => {
57
+ const prev = shared.get(region)
58
+ const renewing = !!prev && prev.by === self && !expired(prev)
59
+ shared.set(region, {
60
+ by: self, intent, at: now(), ttl, maxHold,
61
+ since: renewing ? (prev.since != null ? prev.since : prev.at) : now(),
62
+ })
63
+ }
44
64
  const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
45
65
 
46
66
  // ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it.
@@ -49,6 +69,13 @@ export function makeCoordinator(shared, self, opts = {}) {
49
69
  const acquire = (region, intent) => {
50
70
  const c = shared.get(region)
51
71
  if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
72
+ // Once total hold is exhausted the region is open to everyone — INCLUDING the outgoing
73
+ // holder, which could then win the re-claim race indefinitely and starve the waiters that
74
+ // maxHold exists to admit. So the overrunning holder stands down for a full maxHold, which
75
+ // is a window every waiter is guaranteed to poll inside. A single-ttl cooldown was tried
76
+ // first and is NOT enough: the holder just waits it out and opens a fresh `since`.
77
+ // The bar lapses on its own, so a file nobody else wants returns to its owner.
78
+ if (c && c.by === self && overheld(c) && held(c) < 2 * cap(c)) return false
52
79
  claim(region, intent)
53
80
  const after = shared.get(region)
54
81
  return !(after && after.by !== self) // verify: did my claim survive?
@@ -0,0 +1,173 @@
1
+ // The room mailbox: what has this participant actually read?
2
+ //
3
+ // WHY THIS EXISTS. `hive_wait` used to capture `baseChatLen = chat.length` at the moment it
4
+ // was CALLED, and only ever returned `chat.slice(baseChatLen)`. So anything posted between one
5
+ // wait returning and the next one starting — which is precisely the window where an agent is
6
+ // doing its work — sat below the cursor and was returned by NO code path. Not delayed:
7
+ // unreachable. Two further holes finished the job: a wait that had queued work returned
8
+ // `newChat: []` hardcoded, so the busiest agent in the room saw no messages at all; and
9
+ // mention detection existed only for PAUSED agents, so a working agent could not tell "stop,
10
+ // you are editing the wrong file" from two other agents chatting.
11
+ //
12
+ // Measured on a real four-agent build (JARVIS, 2026-07-28): 345 @mentions in the room log, 13
13
+ // acknowledgements. The PM agent ended up replacing delivery with a bash script that tails
14
+ // HIVE_CHAT.md, which is the verdict on the old design.
15
+ //
16
+ // So the cursor is now a persistent per-participant position that only advances over messages
17
+ // that were actually handed to the agent. This module is deliberately pure — plain arrays in,
18
+ // plain objects out, no Yjs and no I/O — so the delivery rules are unit-testable without a
19
+ // relay, a room, or a network.
20
+
21
+ /** A chat message as stored in the room: { by, kind, at, text }. */
22
+
23
+ /**
24
+ * Does this text address `me`? Matches `@Name`, case-insensitively, at a word boundary so
25
+ * `@PM` does not swallow `@PM-Claude` and `@Hermes` does not match `@Hermes-2`.
26
+ * @param {string} text
27
+ * @param {string} me
28
+ */
29
+ export function mentionsName(text, me) {
30
+ if (!text || !me) return false
31
+ const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ // Trailing boundary: end of string, or anything that cannot continue a name.
33
+ return new RegExp(`@${escaped}(?![\\w-])`, 'i').test(String(text))
34
+ }
35
+
36
+ /** Everyone addressed in a message, so a broadcast can be told from a direct call. */
37
+ export function mentionedNames(text) {
38
+ return [...new Set([...String(text || '').matchAll(/@([\w-]+)/g)].map((m) => m[1]))]
39
+ }
40
+
41
+ /**
42
+ * How urgently does this message want attention?
43
+ *
44
+ * 'stop' — act on it before finishing what you are doing
45
+ * 'normal' — read it at your next boundary
46
+ *
47
+ * A marker is an explicit prefix so it is unambiguous, plus a small set of words that mean
48
+ * stop in every room whether or not the sender knew about markers. An agent about to
49
+ * overwrite someone's work is not going to be helped by correct syntax.
50
+ */
51
+ export function priorityOf(text) {
52
+ const s = String(text || '')
53
+ if (/^\s*\[!\]|^\s*\[stop\]/i.test(s)) return 'stop'
54
+ if (/\b(stop|halt|abort|don'?t\s+(?:edit|touch|write)|revert)\b/i.test(s)) return 'stop'
55
+ return 'normal'
56
+ }
57
+
58
+ /**
59
+ * Collapse runs of the identical message into one entry with a count.
60
+ *
61
+ * Not cosmetic. A conflict-notification feedback loop in the same live build put 56 copies of
62
+ * one "⚠ MERGE CONFLICT in Shell.jsx" line into the room in seconds, and 306 of the room's
63
+ * 2,314 chat lines were that loop. Handing an agent 56 identical lines costs it the context it
64
+ * needs to fix the actual problem, and buries the one message that mattered.
65
+ * @param {Array<{text?: string, by?: string}>} messages
66
+ */
67
+ export function collapseRepeats(messages) {
68
+ const out = []
69
+ for (const m of messages || []) {
70
+ const last = out[out.length - 1]
71
+ if (last && last.by === m.by && last.text === m.text) {
72
+ last.repeated = (last.repeated || 1) + 1
73
+ last.at = m.at ?? last.at // keep the most recent timestamp
74
+ continue
75
+ }
76
+ out.push({ ...m })
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A read cursor over the room chat.
83
+ *
84
+ * @param {{me: string, startAt?: number}} options
85
+ * me this participant's display name
86
+ * startAt where to begin. On a first join this is the current chat length, so an agent is
87
+ * not handed the room's entire history; on a resume it is the persisted cursor, so
88
+ * nothing posted while the process was down is lost.
89
+ */
90
+ export function createMailbox({ me, startAt = 0 }) {
91
+ let cursor = Math.max(0, Number(startAt) || 0)
92
+
93
+ /** Read without consuming. `advance` is what `take` does; kept separate so a peek is safe. */
94
+ function collect(all, { advance }) {
95
+ const list = Array.isArray(all) ? all : []
96
+ // A cursor past the end means this position belongs to a different document — a room
97
+ // rebuilt from scratch, which the recovery procedure for a poisoned room requires. Reset
98
+ // to the beginning rather than clamping to the end: clamping would deliver nothing, for
99
+ // ever, silently. Re-reading a short fresh chat costs an agent a few lines; going
100
+ // permanently deaf costs it the room.
101
+ if (cursor > list.length) cursor = 0
102
+
103
+ const fresh = list.slice(cursor)
104
+ const end = list.length
105
+
106
+ // An agent's own messages are not mail. It used to be woken by its own hive_say, and in a
107
+ // notification storm most of the flood was self-authored — an agent filling its own inbox.
108
+ const incoming = fresh.filter((m) => m && m.by !== me)
109
+ const messages = collapseRepeats(incoming)
110
+ const mentions = messages.filter((m) => mentionsName(m.text, me))
111
+ const urgent = mentions.filter((m) => priorityOf(m.text) === 'stop')
112
+
113
+ if (advance) cursor = end
114
+
115
+ return {
116
+ messages,
117
+ mentions,
118
+ mentioned: mentions.length > 0,
119
+ priority: urgent.length ? 'stop' : 'normal',
120
+ unreadCount: incoming.length,
121
+ cursor,
122
+ }
123
+ }
124
+
125
+ return {
126
+ /** Current position, for persisting across process restarts. */
127
+ get cursor() { return cursor },
128
+ /** Move the cursor without delivering anything (used at join). */
129
+ seek(n) { cursor = Math.max(0, Number(n) || 0); return cursor },
130
+ /** Deliver everything unread and mark it read. Every return path must go through this. */
131
+ take(all) { return collect(all, { advance: true }) },
132
+ /** Look at what is unread without marking it read. */
133
+ peek(all) { return collect(all, { advance: false }) },
134
+ /** Is there anything at all? Cheap enough to call in a wake predicate. */
135
+ hasUnread(all) { return collect(all, { advance: false }).unreadCount > 0 },
136
+ }
137
+ }
138
+
139
+ /** One-line rendering used in tool output, with the repeat count when a run was collapsed. */
140
+ export function formatMessage(m) {
141
+ const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
142
+ return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
143
+ }
144
+
145
+ /**
146
+ * AMBIENT DIGEST -- what to do with room chatter nobody addressed to you.
147
+ *
148
+ * Un-addressed messages were handed to every agent in full, on every wake. In a room of N
149
+ * agents that means each one pays for the other N-1's conversation every single turn: O(N^2)
150
+ * context, so TOKEN COST -- not the relay -- becomes the real ceiling on room size. Measured:
151
+ * the relay holds 8 clients per room, converging in 1.0s on 45 MB. What actually stops a big
152
+ * room is this block of text.
153
+ *
154
+ * The COUNT stays exact and the newest messages still arrive in full. Only the older bodies
155
+ * are withheld, and the agent is told the number and how to read them, so nothing is hidden --
156
+ * it just stops being paid for unasked. An agent that wants the whole log calls hive_read_chat.
157
+ *
158
+ * Returns '' when there is nothing, so a caller can drop it with a falsy check.
159
+ */
160
+ export function ambientDigest(messages, { keep = 4 } = {}) {
161
+ const list = Array.isArray(messages) ? messages : []
162
+ if (!list.length) return ''
163
+ const shown = keep > 0 ? list.slice(-keep) : []
164
+ const hidden = list.length - shown.length
165
+ const head = 'room activity (' + list.length +
166
+ (hidden ? ', newest ' + shown.length + ' shown' : '') + '):'
167
+ const tail = hidden
168
+ ? '\n... ' + hidden + ' earlier message(s) withheld to keep your context small'
169
+ + ' -- hive_read_chat for the full log'
170
+ : ''
171
+ if (!shown.length) return head + tail
172
+ return head + '\n' + shown.map(formatMessage).join('\n') + tail
173
+ }
package/hive-mcp.js CHANGED
@@ -34,7 +34,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
34
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
35
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
36
  import { startSync, parseLink } from './sync.js'
37
- import { createMailbox, formatMessage, mentionsName, priorityOf } from './hive-mailbox.js'
37
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, priorityOf, ambientDigest } from './hive-mailbox.js'
38
38
 
39
39
  const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
40
40
  let session = null // { hive, room, relay, dir, name, mailbox }
@@ -88,7 +88,26 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
88
88
  // persist the token too, so a re-join from .hive.json keeps the grant (the
89
89
  // invitee pastes the link once; subsequent joins just read the folder config).
90
90
  fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
91
- const hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
91
+ // The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
92
+ // asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
93
+ // and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
94
+ // billing credential. startSync resolves the licence from $HIVE_LICENSE (this server's env
95
+ // block) or ~/.hivecode/config.json instead, both of which are per-machine.
96
+ // A refused folder lock must reach the AGENT as a readable instruction, not as a stack
97
+ // trace. An agent that gets an opaque failure retries, and two of them retrying on one
98
+ // folder is precisely the situation the lock exists to prevent.
99
+ let hive
100
+ try {
101
+ hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
102
+ } catch (e) {
103
+ if (e && e.code === 'EHIVELOCKED') {
104
+ return text(
105
+ `${e.message}\n\n` +
106
+ `Do NOT retry this folder. Either join with a different \`dir\`, or stop process ${e.pid} first.`,
107
+ )
108
+ }
109
+ throw e
110
+ }
92
111
  session = { hive, room, relay, dir, name, owner, mailbox: null }
93
112
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
94
113
  // wait for the first sync so members/board/rules are ready
@@ -406,7 +425,20 @@ function enforceDcoLock(opts = {}) {
406
425
  // matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
407
426
  // log while its roommate is named, so the room's own membership record disagrees with itself.
408
427
  // Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
409
- export const VERSION = '0.11.1'
428
+ // VERSION is load-bearing, not cosmetic: the join message (see buildJoinReply) instructs every
429
+ // arriving agent to announce it in chat, because a MIXED BUILD in one room causes data loss and
430
+ // this is the only way anyone finds out. Hardcoding it meant that the moment package.json was
431
+ // bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
432
+ // itself reporting the wrong build. Read the truth instead; package.json always ships in the
433
+ // tarball and sits beside this file in both the repo and the published package.
434
+ // A failed read must never stop the server from starting, so it falls back rather than throwing.
435
+ export const VERSION = (() => {
436
+ try {
437
+ const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
438
+ if (typeof v === 'string' && v) return v
439
+ } catch { /* fall through */ }
440
+ return '0.0.0-unknown'
441
+ })()
410
442
  const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
411
443
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
412
444
  async function dispatch(req) {
@@ -425,7 +457,18 @@ async function dispatch(req) {
425
457
  // a human reading the log, not just to the recipient's tool output.
426
458
  const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
427
459
  requireSession().hive.say(body)
428
- return text(`sent: ${body}`)
460
+ // ADDRESSING IS THE PROTOCOL, and a rule an agent merely read is a rule it forgets once
461
+ // its context fills. So the tool itself answers back: a message that named nobody is
462
+ // reported as landing in everyone's ambient digest, where it is meant to be skipped.
463
+ // Immediate, per-call feedback, at the moment the mistake is made.
464
+ const named = mentionedNames(body)
465
+ if (!named.length) {
466
+ return text(`sent: ${body}\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
467
+ `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
468
+ `agents you actually need with @Name (hive_members lists who is here). Use an ` +
469
+ `unaddressed message only for a genuine announcement to the whole room.`)
470
+ }
471
+ return text(`sent to ${named.map((n) => '@' + n).join(', ')}: ${body}`)
429
472
  }
430
473
  case 'hive_read_chat': {
431
474
  const me = requireSession().name
@@ -456,14 +499,35 @@ async function dispatch(req) {
456
499
  if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
457
500
  const parts = []
458
501
  if (got.priority === 'stop') {
502
+ // HANDLING URGENT MAIL COUNTS AS LIVENESS.
503
+ //
504
+ // Claims evaporate after 5 minutes so a crashed agent never deadlocks the room.
505
+ // But an agent that is preempted -- told to stop and deal with something else --
506
+ // is not crashed, it is obeying. Without this refresh it loses the file it was
507
+ // halfway through editing WHILE DOING WHAT IT WAS ASKED, another agent takes the
508
+ // claim, and it resumes writing into a region it no longer owns. The agent gets
509
+ // punished for complying, and the resulting clobber looks like a merge bug.
510
+ //
511
+ // Re-claiming a region you already hold is safe: acquire() only defers when a
512
+ // DIFFERENT owner holds it, so this refreshes `at` and never steals.
513
+ //
514
+ // One refresh buys another full TTL. A long interruption stays safe because the
515
+ // rules have the agent calling hive_check between steps of the urgent work too.
516
+ try {
517
+ for (const c of s.hive.claimsBoard()) {
518
+ if (c.by === s.name) s.hive.claim(c.region, c.intent || 'edit')
519
+ }
520
+ } catch { /* delivering the urgent message matters more than the refresh */ }
459
521
  parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
522
+ parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
460
523
  parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
461
524
  } else if (got.mentioned) {
462
525
  parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
463
526
  parts.push(got.mentions.map(formatMessage).join('\n'))
464
527
  }
465
528
  const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
466
- if (ambient.length) parts.push(`room activity (${ambient.length}):\n` + ambient.map(formatMessage).join('\n'))
529
+ const dig = ambientDigest(ambient)
530
+ if (dig) parts.push(dig)
467
531
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
468
532
  return text(parts.join('\n\n'))
469
533
  }
@@ -500,10 +564,10 @@ async function dispatch(req) {
500
564
  const mentionBlock = (r) => (r.mentions && r.mentions.length)
501
565
  ? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
502
566
  : ''
503
- const ambientBlock = (r) => {
504
- const rest = (r.messages || []).filter((m) => !mentionsName(m.text, me))
505
- return rest.length ? `room activity:\n${rest.map(formatMessage).join('\n')}` : ''
506
- }
567
+ // Both delivery paths go through the SAME digest. They used to format ambient traffic
568
+ // separately, which is how two copies of a rule drift apart -- the exact hazard that
569
+ // already bit the duplicated reconcile() and the two HIVE_RULES copies.
570
+ const ambientBlock = (r) => ambientDigest((r.messages || []).filter((m) => !mentionsName(m.text, me)))
507
571
 
508
572
  if (r && r.paused) {
509
573
  const parts = [mentionBlock(r), ambientBlock(r)].filter(Boolean)
package/icr.js CHANGED
@@ -546,6 +546,11 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
546
546
  out += baseText.slice(cursor, to)
547
547
  }
548
548
 
549
+ // A KEY REPEATED IN `order` USED TO EMIT ITS UNIT AGAIN. `order` is assembled from both
550
+ // sides' unit lists, so any key present in both can appear twice, and each appearance
551
+ // wrote the declaration out. On a whole-file rewrite that is every declaration in the
552
+ // file, which is how a merge silently doubled a source file instead of merging it.
553
+ // Emit each unit exactly once, at its first ordered position.
549
554
  const seenKeys = new Set()
550
555
  for (const k of order) {
551
556
  if (seenKeys.has(k)) continue
@@ -556,6 +561,7 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
556
561
  emitGap(pos, u.start)
557
562
  pos = u.end
558
563
  }
564
+ // A missing key used to concatenate the string "undefined" into the source.
559
565
  const val = textByKey.get(k)
560
566
  if (val != null) out += val
561
567
  } else {