hivecode-mcp 0.12.1 → 0.12.4
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/LICENSE +21 -21
- package/README.md +188 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +20 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3005
- package/grm-link.cjs +0 -141
package/hive-await.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hive-await — block until someone speaks to you, then exit. The CLI's `hive_wait`.
|
|
3
|
+
//
|
|
4
|
+
// npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]
|
|
5
|
+
//
|
|
6
|
+
// Run it in the BACKGROUND and let its exit wake you. Exits 0 in both cases; read `reason` on stdout:
|
|
7
|
+
// {"reason":"mention","messages":[...]} someone said @YourName or @all -> read, reply, run it again
|
|
8
|
+
// {"reason":"idle","note":"..."} nothing in N minutes -> run it again
|
|
9
|
+
//
|
|
10
|
+
// WHY THIS EXISTS. An agent driving Hivecode from the shell has no way to be interrupted. The
|
|
11
|
+
// only pattern available was a timer:
|
|
12
|
+
//
|
|
13
|
+
// for i in 1 2 3 4 5; do hive-readchat.js ...; sleep 30; done
|
|
14
|
+
//
|
|
15
|
+
// which is deaf by construction. A sleeping process cannot be woken by a message, so the agent
|
|
16
|
+
// is blind for the whole window — and the window is exactly when people are talking to it.
|
|
17
|
+
// Measured on 11 Sep 2026: Ganesh messaged the Director and got no answer for minutes, not
|
|
18
|
+
// because the Director was ignoring him but because it was mid-`sleep`. The agents were alive
|
|
19
|
+
// and deaf, which from the outside is indistinguishable from broken.
|
|
20
|
+
//
|
|
21
|
+
// A timer also makes you choose between two bad things: sleep long and be unresponsive, or
|
|
22
|
+
// sleep short and burn a tool call every few seconds for nothing. This waits on the EVENT.
|
|
23
|
+
// Blocked costs nothing — it is a CRDT observer, not a poll — and it returns the instant your
|
|
24
|
+
// name is said.
|
|
25
|
+
//
|
|
26
|
+
// SHIPPED, 29 Sep 2026. Until then this file lived untracked in one developer's folder, and every
|
|
27
|
+
// agent in a four-machine room got it by path or by paste — the listener the whole product's
|
|
28
|
+
// "stay reachable" rule depends on was not in the package.
|
|
29
|
+
//
|
|
30
|
+
// It joins under YOUR name, beside your MCP connection, tagged `listener`, so the room can say
|
|
31
|
+
// "listening" instead of "parked". It is NOT `passive`: passive means "never answers", and presence
|
|
32
|
+
// is merged by name, so a passive listener would mark the agent itself as one nobody may wait on.
|
|
33
|
+
|
|
34
|
+
import fs from 'fs'
|
|
35
|
+
import path from 'path'
|
|
36
|
+
import { startSync, parseLink } from './sync.js'
|
|
37
|
+
import * as RC from './hive-receipts.js'
|
|
38
|
+
import { awaitDir, armedFile, BEAT_MS } from './hive-listen.js'
|
|
39
|
+
|
|
40
|
+
const [, , LINK = '', NAME = '', IDLE_MIN = '10'] = process.argv
|
|
41
|
+
if (!LINK || !NAME) {
|
|
42
|
+
console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]')
|
|
43
|
+
process.exit(2)
|
|
44
|
+
}
|
|
45
|
+
const { relay, room } = parseLink(LINK)
|
|
46
|
+
const dir = awaitDir(NAME)
|
|
47
|
+
|
|
48
|
+
const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, log: () => {} })
|
|
49
|
+
|
|
50
|
+
// ARMED, AND THE NEWEST LISTENER WINS (4 Oct 2026; see hive-listen.js). This file is how the Stop
|
|
51
|
+
// hook and the MCP server know, with no network call, that the agent will hear the room after its
|
|
52
|
+
// turn ends. It is written at start, not on the first sync: a listener still connecting delivers
|
|
53
|
+
// every mention once it does (check() runs on every resync), and calling it unarmed would only make
|
|
54
|
+
// the agent start a second one. Two listeners for one agent wake it twice for one message, so a
|
|
55
|
+
// listener that finds the file naming another process leaves: that one was started after it.
|
|
56
|
+
const ARMED = armedFile(NAME, room)
|
|
57
|
+
const SESSION = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
58
|
+
const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}`
|
|
59
|
+
const startedAt = Date.now()
|
|
60
|
+
let synced = false
|
|
61
|
+
const ownerPid = () => { try { return Number(JSON.parse(fs.readFileSync(ARMED, 'utf8')).pid) || 0 } catch { return 0 } }
|
|
62
|
+
function beat() {
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(path.dirname(ARMED), { recursive: true })
|
|
65
|
+
fs.writeFileSync(ARMED, JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, at: startedAt, beat: Date.now(), synced }))
|
|
66
|
+
} catch { /* unarmed is the safe reading: the hook asks for a fresh listener, and it replaces this one */ }
|
|
67
|
+
}
|
|
68
|
+
const superseded = () => {
|
|
69
|
+
const o = ownerPid()
|
|
70
|
+
if (!o || o === process.pid) return false
|
|
71
|
+
finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${o}). Do not start this one again.` })
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
beat()
|
|
75
|
+
setInterval(() => { if (!superseded()) beat() }, BEAT_MS).unref()
|
|
76
|
+
process.on('exit', () => { try { if (ownerPid() === process.pid) fs.unlinkSync(ARMED) } catch { } })
|
|
77
|
+
const chat = hive.doc.getArray('chat')
|
|
78
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
79
|
+
// `@all` wakes everyone — it is how the room is addressed deliberately. `\b` is NOT enough: "-" is a
|
|
80
|
+
// word boundary, so `@Claude\b` fired on `@Claude-2`, a different participant. A name ends where no
|
|
81
|
+
// name character follows. `@all` must not match `@allen` either.
|
|
82
|
+
const rx = new RegExp(`@(${esc(NAME)}|all)(?![\\w-])`, 'i')
|
|
83
|
+
let armed = false
|
|
84
|
+
let finishing = false
|
|
85
|
+
|
|
86
|
+
// `flushMs`: when this exit just moved the room's read cursor, give that update time to leave the
|
|
87
|
+
// socket. Exiting at once can drop it, and the MCP side would announce the same mail again.
|
|
88
|
+
async function finish(reason, payload, flushMs = 0) {
|
|
89
|
+
if (finishing) return
|
|
90
|
+
finishing = true
|
|
91
|
+
if (flushMs) {
|
|
92
|
+
const ws = hive.provider && hive.provider.ws
|
|
93
|
+
const end = Date.now() + flushMs
|
|
94
|
+
while (Date.now() < end && ws && ws.readyState === 1 && ws.bufferedAmount > 0) await new Promise((r) => setTimeout(r, 25))
|
|
95
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
96
|
+
}
|
|
97
|
+
console.log(JSON.stringify({ reason, ...payload }))
|
|
98
|
+
try { hive.stop() } catch {}
|
|
99
|
+
process.exit(0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// THE ROOM'S CURSOR IS THE SHARED ONE (3 Oct 2026). This process and the agent's MCP server each
|
|
103
|
+
// kept their own: a message delivered here was still unread there, so the next tool call announced
|
|
104
|
+
// it again as "UNREAD MAIL ... Handle before continuing" (19 old messages in one session), and mail
|
|
105
|
+
// the agent read through hive_check could wake a fresh listener a second time. Both now meet at the
|
|
106
|
+
// room's per-member cursor (hive-receipts.js), which only moves forward. Publishing it here is also
|
|
107
|
+
// what lets a sender's hive_delivered say READ for mail this listener handed over.
|
|
108
|
+
const cursors = hive.doc.getMap('cursors')
|
|
109
|
+
const roomCursor = (len) => {
|
|
110
|
+
try { const c = RC.cursorOf(cursors, NAME); const at = (c && Number(c.at)) || 0; return at <= len ? at : 0 } catch { return 0 }
|
|
111
|
+
}
|
|
112
|
+
const publish = (n) => { try { RC.publishCursor(cursors, NAME, n) } catch { /* a room that predates receipts */ } }
|
|
113
|
+
|
|
114
|
+
// A PERSISTED CURSOR, not just "my last message".
|
|
115
|
+
//
|
|
116
|
+
// The first version anchored only on the last thing I said. That is fine for an agent that
|
|
117
|
+
// replies to everything, and a livelock for one that listens without speaking: `lastMine` stays
|
|
118
|
+
// -1, so every call returns the SAME old messages, the caller loops, and it never advances.
|
|
119
|
+
// Measured 11 Sep 2026 — Builder's watch loop ran 420 iterations over ~42 minutes re-reading the
|
|
120
|
+
// same two messages, and the respawning that caused left orphaned processes holding room
|
|
121
|
+
// presence after the agent itself had died.
|
|
122
|
+
//
|
|
123
|
+
// So the cursor is written to disk and advanced on every exit. `lastMine` is still used, as a
|
|
124
|
+
// FLOOR: anything I have already replied to is handled by definition, even if a stale cursor
|
|
125
|
+
// says otherwise. Whichever is further along wins.
|
|
126
|
+
// AN ORPHAN MUST NOT KEEP SAYING "LISTENING" (2 Oct 2026). When the app that started this listener
|
|
127
|
+
// closes, the listener can outlive it: on macOS and Linux it is reparented, and on any OS a detached
|
|
128
|
+
// start escapes the parent's job. It then keeps its agent marked LISTENING, reachable, in every
|
|
129
|
+
// teammate's hive_members, and wakes nobody when they call. So it watches the process that started
|
|
130
|
+
// it and leaves the room once that process is gone. HIVE_AWAIT_KEEP_ALIVE=1 opts out, for a listener
|
|
131
|
+
// that is detached on purpose. (tests/hive-await-test.mjs, "an orphaned listener")
|
|
132
|
+
const PARENT = process.ppid
|
|
133
|
+
const parentGone = () => {
|
|
134
|
+
if (process.ppid !== PARENT) return true // reparented: the parent exited
|
|
135
|
+
try { process.kill(PARENT, 0); return false } catch (e) { return e.code !== 'EPERM' }
|
|
136
|
+
}
|
|
137
|
+
if (process.env.HIVE_AWAIT_KEEP_ALIVE !== '1' && PARENT > 1 && !parentGone()) {
|
|
138
|
+
setInterval(() => {
|
|
139
|
+
if (parentGone()) finish('parent-gone', { note: `the process that started this listener exited, so ${NAME} left the room` })
|
|
140
|
+
}, Number(process.env.HIVE_AWAIT_PARENT_CHECK_MS) || 15000).unref()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cursorFile = path.join(dir, `.await-cursor-${room}.json`)
|
|
144
|
+
const readCursor = () => { try { return Number(JSON.parse(fs.readFileSync(cursorFile, 'utf8')).at) || 0 } catch { return 0 } }
|
|
145
|
+
const writeCursor = (n) => { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cursorFile, JSON.stringify({ at: n }), 'utf8') } catch { /* a cursor we cannot persist must not break the wait */ } }
|
|
146
|
+
|
|
147
|
+
function check() {
|
|
148
|
+
if (superseded()) return // the newer listener delivers it; consuming it here would hide it from that one
|
|
149
|
+
const msgs = chat.toArray()
|
|
150
|
+
let lastMine = -1
|
|
151
|
+
for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].by === NAME) { lastMine = i; break } }
|
|
152
|
+
const from = Math.max(readCursor(), lastMine + 1, roomCursor(msgs.length))
|
|
153
|
+
const hits = []
|
|
154
|
+
for (let i = from; i < msgs.length; i++) {
|
|
155
|
+
const m = msgs[i]
|
|
156
|
+
if (rx.test(String(m.text || '')) && m.by !== NAME) hits.push(m)
|
|
157
|
+
}
|
|
158
|
+
// Advance past everything READ, not just everything matched — an unaddressed message I have
|
|
159
|
+
// already seen must not be re-scanned on the next call either.
|
|
160
|
+
if (hits.length) {
|
|
161
|
+
writeCursor(msgs.length)
|
|
162
|
+
publish(msgs.length)
|
|
163
|
+
// THE ORDER TO START AGAIN RIDES ON EVERY WAKE. It used to be said once, on the agent's first
|
|
164
|
+
// quiet hive_wait, and an agent had to remember it after every wake; agents forget (4 Oct 2026).
|
|
165
|
+
finish('mention', { messages: hits.map((m) => `${m.by}(${m.kind}): ${m.text}`), next: `When you have answered, start this listener again in the background, the same way you started it, or you stop hearing the room when your turn ends: ${AGAIN}` }, 1500)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
hive.provider.on('sync', (s) => {
|
|
170
|
+
if (!s) return
|
|
171
|
+
if (!synced) { synced = true; beat() }
|
|
172
|
+
if (!armed) {
|
|
173
|
+
armed = true
|
|
174
|
+
console.error(`[await] ${NAME} listening in ${room}. In room: ${hive.members().map((m) => m.name).join(', ')}`)
|
|
175
|
+
chat.observe(check)
|
|
176
|
+
}
|
|
177
|
+
check() // also on every resync — mentions may have landed while the socket was down
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// An idle timeout also advances the cursor. Without this, a quiet window followed by a loud one
|
|
181
|
+
// would re-deliver everything that arrived during the quiet window too.
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
try { writeCursor(chat.length) } catch {}
|
|
184
|
+
publish(chat.length) // nothing in the window named us, so nothing in it is mail the MCP should raise
|
|
185
|
+
finish('idle', { note: `nothing addressed to ${NAME} in ${IDLE_MIN} min. Run this same command again in the background to keep listening: ${AGAIN}` }, 1500)
|
|
186
|
+
}, Number(IDLE_MIN) * 60 * 1000)
|
|
187
|
+
process.on('SIGINT', () => { try { hive.stop() } catch {}; process.exit(0) })
|