hivecode-mcp 0.14.1 → 0.14.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.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/hive-authors.js
CHANGED
|
@@ -1,44 +1,46 @@
|
|
|
1
|
-
// hive-authors.js -- who wrote what the room holds: the ONE authorsOf, shared by sync.js and hive-pr.js. A receipt counts only when verify(r) === true or verify(r).ok === true (a truthy {ok:false} used to count).
|
|
2
|
-
import { contentHash } from './substrate.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Check whether a receipt is valid according to the optional verify function.
|
|
6
|
-
* Handles both boolean returns and { ok: boolean } result objects (verifyReceipt).
|
|
7
|
-
*/
|
|
8
|
-
export function isReceiptValid(verify, r) {
|
|
9
|
-
if (!verify) return true
|
|
10
|
-
const v = verify(r)
|
|
11
|
-
return v === true || Boolean(v && v.ok)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The authors of a file's held content, newest-landed first, from verified receipts only.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
// hive-authors.js -- who wrote what the room holds: the ONE authorsOf, shared by sync.js and hive-pr.js. A receipt counts only when verify(r) === true or verify(r).ok === true (a truthy {ok:false} used to count).
|
|
2
|
+
import { contentHash } from './substrate.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check whether a receipt is valid according to the optional verify function.
|
|
6
|
+
* Handles both boolean returns and { ok: boolean } result objects (verifyReceipt).
|
|
7
|
+
*/
|
|
8
|
+
export function isReceiptValid(verify, r) {
|
|
9
|
+
if (!verify) return true
|
|
10
|
+
const v = verify(r)
|
|
11
|
+
return v === true || Boolean(v && v.ok)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The authors of a file's held content, newest-landed first, from verified receipts only.
|
|
16
|
+
*/
|
|
17
|
+
const toLF = (s) => (typeof s === 'string' && s.indexOf('\r\n') !== -1 ? s.replace(/\r\n/g, '\n') : s)
|
|
18
|
+
|
|
19
|
+
export function authorsOf(receipts, verify, roomText) {
|
|
20
|
+
const ok = (receipts || []).filter((r) => r && r.author && isReceiptValid(verify, r))
|
|
21
|
+
if (!ok.length) return { last: null, contributors: [], first: 0, receipts: [] }
|
|
22
|
+
const want = typeof roomText === 'string' ? contentHash(toLF(roomText)) : null
|
|
23
|
+
let idx = -1
|
|
24
|
+
if (want) {
|
|
25
|
+
for (let i = ok.length - 1; i >= 0; i--) {
|
|
26
|
+
if (ok[i].contentHash === want) {
|
|
27
|
+
idx = i
|
|
28
|
+
break
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
|
|
33
|
+
const last = held[held.length - 1]
|
|
34
|
+
const seen = new Map()
|
|
35
|
+
for (const r of held) {
|
|
36
|
+
if (!seen.has(r.author)) {
|
|
37
|
+
seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
last: last ? { author: last.author, name: last.name || 'agent', at: last.at || 0 } : null,
|
|
42
|
+
contributors: [...seen.values()],
|
|
43
|
+
first: Math.min(...ok.map((r) => r.at || 0)),
|
|
44
|
+
receipts: held,
|
|
45
|
+
}
|
|
46
|
+
}
|
package/hive-await.js
CHANGED
|
@@ -1,230 +1,230 @@
|
|
|
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] [app]
|
|
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', APP_ARG = ''] = process.argv
|
|
41
|
-
if (!LINK || !NAME) {
|
|
42
|
-
console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]')
|
|
43
|
-
process.exit(2)
|
|
44
|
-
}
|
|
45
|
-
const { relay, room } = parseLink(LINK)
|
|
46
|
-
const dir = awaitDir(NAME)
|
|
47
|
-
// Which app will be woken when this listener exits: passed by listenCommand, or inferred from the
|
|
48
|
-
// session environment when the agent ran `node hive-await.js <link> <name> <min>` directly. Without
|
|
49
|
-
// `app` here, a listener running while the MCP connection is closed or restarting leaves `app`
|
|
50
|
-
// undefined in awareness, and hive-activity.js misreports the agent as "not woken by it".
|
|
51
|
-
const APP = APP_ARG || process.env.HIVE_APP || (process.env.CLAUDE_CODE_SESSION_ID ? 'claude-code' : (process.env.ANTIGRAVITY_CONVERSATION_ID || process.env.ANTIGRAVITY_AGENT ? 'antigravity' : ''))
|
|
52
|
-
|
|
53
|
-
const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, app: APP, log: () => {} })
|
|
54
|
-
|
|
55
|
-
// ARMED, AND THE NEWEST LISTENER WINS (4 Oct 2026; see hive-listen.js). This file is how the Stop
|
|
56
|
-
// hook and the MCP server know, with no network call, that the agent will hear the room after its
|
|
57
|
-
// turn ends. It is written at start, not on the first sync: a listener still connecting delivers
|
|
58
|
-
// every mention once it does (check() runs on every resync), and calling it unarmed would only make
|
|
59
|
-
// the agent start a second one. Two listeners for one agent wake it twice for one message, so a
|
|
60
|
-
// listener that finds the file naming another process leaves: that one was started after it.
|
|
61
|
-
const ARMED = armedFile(NAME, room)
|
|
62
|
-
const SESSION = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
63
|
-
const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}${APP_ARG ? ` "${APP_ARG}"` : ''}`
|
|
64
|
-
const startedAt = Date.now()
|
|
65
|
-
let synced = false
|
|
66
|
-
const readArmed = () => { try { return JSON.parse(fs.readFileSync(ARMED, 'utf8')) } catch { return null } }
|
|
67
|
-
const ownerPid = () => Number((readArmed() || {}).pid) || 0
|
|
68
|
-
const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
69
|
-
// THE NEWEST BY START TIME WINS, NOT THE LAST ONE TO WRITE (6 Oct 2026, the first macOS run). Every
|
|
70
|
-
// listener reads the file and then rewrites it on each beat. An older listener that read it just
|
|
71
|
-
// before a newer one wrote it put its own claim back on top, and the NEWER one then left as
|
|
72
|
-
// "superseded" while the older stayed: N1 and N3 failed on macOS, never on Windows. So a claim is
|
|
73
|
-
// judged by when its listener started. A listener yields only to one that started after it and is
|
|
74
|
-
// still running; a newer one that finds an older claim on top takes the file back on its next beat.
|
|
75
|
-
// The file is also written whole (a temp file renamed over it), so a reader never sees half of one.
|
|
76
|
-
const newerThanMe = (rec) => {
|
|
77
|
-
const pid = Number(rec && rec.pid) || 0
|
|
78
|
-
if (!pid || pid === process.pid || !alive(pid)) return false
|
|
79
|
-
const at = Number(rec.at) || 0
|
|
80
|
-
return at > startedAt || (at === startedAt && pid > process.pid)
|
|
81
|
-
}
|
|
82
|
-
function beat() {
|
|
83
|
-
try {
|
|
84
|
-
fs.mkdirSync(path.dirname(ARMED), { recursive: true })
|
|
85
|
-
const body = JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, ...(APP ? { app: APP } : {}), at: startedAt, beat: Date.now(), synced })
|
|
86
|
-
const part = `${ARMED}.${process.pid}.tmp`
|
|
87
|
-
try { fs.writeFileSync(part, body); fs.renameSync(part, ARMED) } catch {
|
|
88
|
-
try { fs.rmSync(part, { force: true }) } catch {}
|
|
89
|
-
fs.writeFileSync(ARMED, body)
|
|
90
|
-
}
|
|
91
|
-
} catch { /* unarmed is the safe reading: the hook asks for a fresh listener, and it replaces this one */ }
|
|
92
|
-
}
|
|
93
|
-
const superseded = () => {
|
|
94
|
-
const rec = readArmed()
|
|
95
|
-
if (!newerThanMe(rec)) return false
|
|
96
|
-
finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${rec.pid}). Do not start this one again.` })
|
|
97
|
-
return true
|
|
98
|
-
}
|
|
99
|
-
beat()
|
|
100
|
-
setInterval(() => { if (!superseded()) beat() }, BEAT_MS).unref()
|
|
101
|
-
process.on('exit', () => { try { if (ownerPid() === process.pid) fs.unlinkSync(ARMED) } catch { } })
|
|
102
|
-
const chat = hive.doc.getArray('chat')
|
|
103
|
-
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
104
|
-
// `@all` wakes everyone — it is how the room is addressed deliberately. `\b` is NOT enough: "-" is a
|
|
105
|
-
// word boundary, so `@Claude\b` fired on `@Claude-2`, a different participant. A name ends where no
|
|
106
|
-
// name character follows. `@all` must not match `@allen` either.
|
|
107
|
-
const rx = new RegExp(`@(${esc(NAME)}|all)(?![\\w-])`, 'i')
|
|
108
|
-
let armed = false
|
|
109
|
-
let finishing = false
|
|
110
|
-
|
|
111
|
-
// `flushMs`: when this exit just moved the room's read cursor, give that update time to leave the
|
|
112
|
-
// socket. Exiting at once can drop it, and the MCP side would announce the same mail again.
|
|
113
|
-
async function finish(reason, payload, flushMs = 0) {
|
|
114
|
-
if (finishing) return
|
|
115
|
-
finishing = true
|
|
116
|
-
if (flushMs) {
|
|
117
|
-
const ws = hive.provider && hive.provider.ws
|
|
118
|
-
const end = Date.now() + flushMs
|
|
119
|
-
while (Date.now() < end && ws && ws.readyState === 1 && ws.bufferedAmount > 0) await new Promise((r) => setTimeout(r, 25))
|
|
120
|
-
await new Promise((r) => setTimeout(r, 200))
|
|
121
|
-
}
|
|
122
|
-
console.log(JSON.stringify({ reason, ...payload }))
|
|
123
|
-
try { hive.stop() } catch {}
|
|
124
|
-
process.exit(0)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// THE ROOM'S CURSOR IS THE SHARED ONE (3 Oct 2026). This process and the agent's MCP server each
|
|
128
|
-
// kept their own: a message delivered here was still unread there, so the next tool call announced
|
|
129
|
-
// it again as "UNREAD MAIL ... Handle before continuing" (19 old messages in one session), and mail
|
|
130
|
-
// the agent read through hive_check could wake a fresh listener a second time. Both now meet at the
|
|
131
|
-
// room's per-member cursor (hive-receipts.js), which only moves forward. Publishing it here is also
|
|
132
|
-
// what lets a sender's hive_delivered say READ for mail this listener handed over.
|
|
133
|
-
const cursors = hive.doc.getMap('cursors')
|
|
134
|
-
const roomCursor = (len) => {
|
|
135
|
-
try { const c = RC.cursorOf(cursors, NAME); const at = (c && Number(c.at)) || 0; return at <= len ? at : 0 } catch { return 0 }
|
|
136
|
-
}
|
|
137
|
-
const publish = (n) => { try { RC.publishCursor(cursors, NAME, n) } catch { /* a room that predates receipts */ } }
|
|
138
|
-
|
|
139
|
-
// A PERSISTED CURSOR, not just "my last message".
|
|
140
|
-
//
|
|
141
|
-
// The first version anchored only on the last thing I said. That is fine for an agent that
|
|
142
|
-
// replies to everything, and a livelock for one that listens without speaking: `lastMine` stays
|
|
143
|
-
// -1, so every call returns the SAME old messages, the caller loops, and it never advances.
|
|
144
|
-
// Measured 11 Sep 2026 — Builder's watch loop ran 420 iterations over ~42 minutes re-reading the
|
|
145
|
-
// same two messages, and the respawning that caused left orphaned processes holding room
|
|
146
|
-
// presence after the agent itself had died.
|
|
147
|
-
//
|
|
148
|
-
// So the cursor is written to disk and advanced on every exit. `lastMine` is still used, as a
|
|
149
|
-
// FLOOR: anything I have already replied to is handled by definition, even if a stale cursor
|
|
150
|
-
// says otherwise. Whichever is further along wins.
|
|
151
|
-
// AN ORPHAN MUST NOT KEEP SAYING "LISTENING" (2 Oct 2026). When the app that started this listener
|
|
152
|
-
// closes, the listener can outlive it: on macOS and Linux it is reparented, and on any OS a detached
|
|
153
|
-
// start escapes the parent's job. It then keeps its agent marked LISTENING, reachable, in every
|
|
154
|
-
// teammate's hive_members, and wakes nobody when they call. So it watches the process that started
|
|
155
|
-
// it and leaves the room once that process is gone. HIVE_AWAIT_KEEP_ALIVE=1 opts out, for a listener
|
|
156
|
-
// that is detached on purpose. (tests/hive-await-test.mjs, "an orphaned listener")
|
|
157
|
-
const PARENT = process.ppid
|
|
158
|
-
const parentGone = () => {
|
|
159
|
-
if (process.ppid !== PARENT) return true // reparented: the parent exited
|
|
160
|
-
try { process.kill(PARENT, 0); return false } catch (e) { return e.code !== 'EPERM' }
|
|
161
|
-
}
|
|
162
|
-
if (process.env.HIVE_AWAIT_KEEP_ALIVE !== '1' && PARENT > 1 && !parentGone()) {
|
|
163
|
-
setInterval(() => {
|
|
164
|
-
if (parentGone()) finish('parent-gone', { note: `the process that started this listener exited, so ${NAME} left the room` })
|
|
165
|
-
}, Number(process.env.HIVE_AWAIT_PARENT_CHECK_MS) || 15000).unref()
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// A REBUILT ROOM MUST NOT LEAVE THE LISTENER DEAF (7 Nov 2026, found by antigravity-2). A relay
|
|
169
|
-
// that restarts without its rooms (every deploy on Render's free plan) starts chat again from zero,
|
|
170
|
-
// but the cursor on disk still said, say, 300: scanning from 300 of a 3-message chat found nothing,
|
|
171
|
-
// so the agent showed LISTENING and no mention woke it until the idle timeout, which then moved the
|
|
172
|
-
// cursor past everything it had missed. So the cursor records which room it was counted in (its
|
|
173
|
-
// first message) and is ignored when that differs, or when it points past the end -- the rule the
|
|
174
|
-
// room's cursor above and the MCP's catch-up already follow. Chat is never trimmed, so within one
|
|
175
|
-
// room the first message never changes. An older cursor file without `head` is only clamped.
|
|
176
|
-
// (tests/await-rebuilt-room-test.mjs)
|
|
177
|
-
const cursorFile = path.join(dir, `.await-cursor-${room}.json`)
|
|
178
|
-
const headOf = (msgs) => { const m = msgs[0]; return m ? `${m.by}|${m.at}|${String(m.text || '').slice(0, 80)}` : '' }
|
|
179
|
-
const readCursor = (msgs) => {
|
|
180
|
-
try {
|
|
181
|
-
const c = JSON.parse(fs.readFileSync(cursorFile, 'utf8'))
|
|
182
|
-
const at = Number(c.at) || 0
|
|
183
|
-
if (at > msgs.length) return 0
|
|
184
|
-
if (typeof c.head === 'string' && c.head && c.head !== headOf(msgs)) return 0
|
|
185
|
-
return at
|
|
186
|
-
} catch { return 0 }
|
|
187
|
-
}
|
|
188
|
-
const writeCursor = (n, msgs) => { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cursorFile, JSON.stringify({ at: n, head: headOf(msgs) }), 'utf8') } catch { /* a cursor we cannot persist must not break the wait */ } }
|
|
189
|
-
|
|
190
|
-
function check() {
|
|
191
|
-
if (superseded()) return // the newer listener delivers it; consuming it here would hide it from that one
|
|
192
|
-
const msgs = chat.toArray()
|
|
193
|
-
let lastMine = -1
|
|
194
|
-
for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].by === NAME) { lastMine = i; break } }
|
|
195
|
-
const from = Math.max(readCursor(msgs), lastMine + 1, roomCursor(msgs.length))
|
|
196
|
-
const hits = []
|
|
197
|
-
for (let i = from; i < msgs.length; i++) {
|
|
198
|
-
const m = msgs[i]
|
|
199
|
-
if (rx.test(String(m.text || '')) && m.by !== NAME) hits.push(m)
|
|
200
|
-
}
|
|
201
|
-
// Advance past everything READ, not just everything matched — an unaddressed message I have
|
|
202
|
-
// already seen must not be re-scanned on the next call either.
|
|
203
|
-
if (hits.length) {
|
|
204
|
-
writeCursor(msgs.length, msgs)
|
|
205
|
-
publish(msgs.length)
|
|
206
|
-
// THE ORDER TO START AGAIN RIDES ON EVERY WAKE. It used to be said once, on the agent's first
|
|
207
|
-
// quiet hive_wait, and an agent had to remember it after every wake; agents forget (4 Oct 2026).
|
|
208
|
-
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)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
hive.provider.on('sync', (s) => {
|
|
213
|
-
if (!s) return
|
|
214
|
-
if (!synced) { synced = true; beat() }
|
|
215
|
-
if (!armed) {
|
|
216
|
-
armed = true
|
|
217
|
-
console.error(`[await] ${NAME} listening in ${room}. In room: ${hive.members().map((m) => m.name).join(', ')}`)
|
|
218
|
-
chat.observe(check)
|
|
219
|
-
}
|
|
220
|
-
check() // also on every resync — mentions may have landed while the socket was down
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
// An idle timeout also advances the cursor. Without this, a quiet window followed by a loud one
|
|
224
|
-
// would re-deliver everything that arrived during the quiet window too.
|
|
225
|
-
setTimeout(() => {
|
|
226
|
-
try { writeCursor(chat.length, chat.toArray()) } catch {}
|
|
227
|
-
publish(chat.length) // nothing in the window named us, so nothing in it is mail the MCP should raise
|
|
228
|
-
finish('idle', { note: `nothing addressed to ${NAME} in ${IDLE_MIN} min. Run this same command again in the background to keep listening: ${AGAIN}` }, 1500)
|
|
229
|
-
}, Number(IDLE_MIN) * 60 * 1000)
|
|
230
|
-
process.on('SIGINT', () => { try { hive.stop() } catch {}; process.exit(0) })
|
|
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] [app]
|
|
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', APP_ARG = ''] = process.argv
|
|
41
|
+
if (!LINK || !NAME) {
|
|
42
|
+
console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]')
|
|
43
|
+
process.exit(2)
|
|
44
|
+
}
|
|
45
|
+
const { relay, room } = parseLink(LINK)
|
|
46
|
+
const dir = awaitDir(NAME)
|
|
47
|
+
// Which app will be woken when this listener exits: passed by listenCommand, or inferred from the
|
|
48
|
+
// session environment when the agent ran `node hive-await.js <link> <name> <min>` directly. Without
|
|
49
|
+
// `app` here, a listener running while the MCP connection is closed or restarting leaves `app`
|
|
50
|
+
// undefined in awareness, and hive-activity.js misreports the agent as "not woken by it".
|
|
51
|
+
const APP = APP_ARG || process.env.HIVE_APP || (process.env.CLAUDE_CODE_SESSION_ID ? 'claude-code' : (process.env.ANTIGRAVITY_CONVERSATION_ID || process.env.ANTIGRAVITY_AGENT ? 'antigravity' : ''))
|
|
52
|
+
|
|
53
|
+
const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, app: APP, log: () => {} })
|
|
54
|
+
|
|
55
|
+
// ARMED, AND THE NEWEST LISTENER WINS (4 Oct 2026; see hive-listen.js). This file is how the Stop
|
|
56
|
+
// hook and the MCP server know, with no network call, that the agent will hear the room after its
|
|
57
|
+
// turn ends. It is written at start, not on the first sync: a listener still connecting delivers
|
|
58
|
+
// every mention once it does (check() runs on every resync), and calling it unarmed would only make
|
|
59
|
+
// the agent start a second one. Two listeners for one agent wake it twice for one message, so a
|
|
60
|
+
// listener that finds the file naming another process leaves: that one was started after it.
|
|
61
|
+
const ARMED = armedFile(NAME, room)
|
|
62
|
+
const SESSION = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
63
|
+
const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}${APP_ARG ? ` "${APP_ARG}"` : ''}`
|
|
64
|
+
const startedAt = Date.now()
|
|
65
|
+
let synced = false
|
|
66
|
+
const readArmed = () => { try { return JSON.parse(fs.readFileSync(ARMED, 'utf8')) } catch { return null } }
|
|
67
|
+
const ownerPid = () => Number((readArmed() || {}).pid) || 0
|
|
68
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
69
|
+
// THE NEWEST BY START TIME WINS, NOT THE LAST ONE TO WRITE (6 Oct 2026, the first macOS run). Every
|
|
70
|
+
// listener reads the file and then rewrites it on each beat. An older listener that read it just
|
|
71
|
+
// before a newer one wrote it put its own claim back on top, and the NEWER one then left as
|
|
72
|
+
// "superseded" while the older stayed: N1 and N3 failed on macOS, never on Windows. So a claim is
|
|
73
|
+
// judged by when its listener started. A listener yields only to one that started after it and is
|
|
74
|
+
// still running; a newer one that finds an older claim on top takes the file back on its next beat.
|
|
75
|
+
// The file is also written whole (a temp file renamed over it), so a reader never sees half of one.
|
|
76
|
+
const newerThanMe = (rec) => {
|
|
77
|
+
const pid = Number(rec && rec.pid) || 0
|
|
78
|
+
if (!pid || pid === process.pid || !alive(pid)) return false
|
|
79
|
+
const at = Number(rec.at) || 0
|
|
80
|
+
return at > startedAt || (at === startedAt && pid > process.pid)
|
|
81
|
+
}
|
|
82
|
+
function beat() {
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(path.dirname(ARMED), { recursive: true })
|
|
85
|
+
const body = JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, ...(APP ? { app: APP } : {}), at: startedAt, beat: Date.now(), synced })
|
|
86
|
+
const part = `${ARMED}.${process.pid}.tmp`
|
|
87
|
+
try { fs.writeFileSync(part, body); fs.renameSync(part, ARMED) } catch {
|
|
88
|
+
try { fs.rmSync(part, { force: true }) } catch {}
|
|
89
|
+
fs.writeFileSync(ARMED, body)
|
|
90
|
+
}
|
|
91
|
+
} catch { /* unarmed is the safe reading: the hook asks for a fresh listener, and it replaces this one */ }
|
|
92
|
+
}
|
|
93
|
+
const superseded = () => {
|
|
94
|
+
const rec = readArmed()
|
|
95
|
+
if (!newerThanMe(rec)) return false
|
|
96
|
+
finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${rec.pid}). Do not start this one again.` })
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
beat()
|
|
100
|
+
setInterval(() => { if (!superseded()) beat() }, BEAT_MS).unref()
|
|
101
|
+
process.on('exit', () => { try { if (ownerPid() === process.pid) fs.unlinkSync(ARMED) } catch { } })
|
|
102
|
+
const chat = hive.doc.getArray('chat')
|
|
103
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
104
|
+
// `@all` wakes everyone — it is how the room is addressed deliberately. `\b` is NOT enough: "-" is a
|
|
105
|
+
// word boundary, so `@Claude\b` fired on `@Claude-2`, a different participant. A name ends where no
|
|
106
|
+
// name character follows. `@all` must not match `@allen` either.
|
|
107
|
+
const rx = new RegExp(`@(${esc(NAME)}|all)(?![\\w-])`, 'i')
|
|
108
|
+
let armed = false
|
|
109
|
+
let finishing = false
|
|
110
|
+
|
|
111
|
+
// `flushMs`: when this exit just moved the room's read cursor, give that update time to leave the
|
|
112
|
+
// socket. Exiting at once can drop it, and the MCP side would announce the same mail again.
|
|
113
|
+
async function finish(reason, payload, flushMs = 0) {
|
|
114
|
+
if (finishing) return
|
|
115
|
+
finishing = true
|
|
116
|
+
if (flushMs) {
|
|
117
|
+
const ws = hive.provider && hive.provider.ws
|
|
118
|
+
const end = Date.now() + flushMs
|
|
119
|
+
while (Date.now() < end && ws && ws.readyState === 1 && ws.bufferedAmount > 0) await new Promise((r) => setTimeout(r, 25))
|
|
120
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
121
|
+
}
|
|
122
|
+
console.log(JSON.stringify({ reason, ...payload }))
|
|
123
|
+
try { hive.stop() } catch {}
|
|
124
|
+
process.exit(0)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// THE ROOM'S CURSOR IS THE SHARED ONE (3 Oct 2026). This process and the agent's MCP server each
|
|
128
|
+
// kept their own: a message delivered here was still unread there, so the next tool call announced
|
|
129
|
+
// it again as "UNREAD MAIL ... Handle before continuing" (19 old messages in one session), and mail
|
|
130
|
+
// the agent read through hive_check could wake a fresh listener a second time. Both now meet at the
|
|
131
|
+
// room's per-member cursor (hive-receipts.js), which only moves forward. Publishing it here is also
|
|
132
|
+
// what lets a sender's hive_delivered say READ for mail this listener handed over.
|
|
133
|
+
const cursors = hive.doc.getMap('cursors')
|
|
134
|
+
const roomCursor = (len) => {
|
|
135
|
+
try { const c = RC.cursorOf(cursors, NAME); const at = (c && Number(c.at)) || 0; return at <= len ? at : 0 } catch { return 0 }
|
|
136
|
+
}
|
|
137
|
+
const publish = (n) => { try { RC.publishCursor(cursors, NAME, n) } catch { /* a room that predates receipts */ } }
|
|
138
|
+
|
|
139
|
+
// A PERSISTED CURSOR, not just "my last message".
|
|
140
|
+
//
|
|
141
|
+
// The first version anchored only on the last thing I said. That is fine for an agent that
|
|
142
|
+
// replies to everything, and a livelock for one that listens without speaking: `lastMine` stays
|
|
143
|
+
// -1, so every call returns the SAME old messages, the caller loops, and it never advances.
|
|
144
|
+
// Measured 11 Sep 2026 — Builder's watch loop ran 420 iterations over ~42 minutes re-reading the
|
|
145
|
+
// same two messages, and the respawning that caused left orphaned processes holding room
|
|
146
|
+
// presence after the agent itself had died.
|
|
147
|
+
//
|
|
148
|
+
// So the cursor is written to disk and advanced on every exit. `lastMine` is still used, as a
|
|
149
|
+
// FLOOR: anything I have already replied to is handled by definition, even if a stale cursor
|
|
150
|
+
// says otherwise. Whichever is further along wins.
|
|
151
|
+
// AN ORPHAN MUST NOT KEEP SAYING "LISTENING" (2 Oct 2026). When the app that started this listener
|
|
152
|
+
// closes, the listener can outlive it: on macOS and Linux it is reparented, and on any OS a detached
|
|
153
|
+
// start escapes the parent's job. It then keeps its agent marked LISTENING, reachable, in every
|
|
154
|
+
// teammate's hive_members, and wakes nobody when they call. So it watches the process that started
|
|
155
|
+
// it and leaves the room once that process is gone. HIVE_AWAIT_KEEP_ALIVE=1 opts out, for a listener
|
|
156
|
+
// that is detached on purpose. (tests/hive-await-test.mjs, "an orphaned listener")
|
|
157
|
+
const PARENT = process.ppid
|
|
158
|
+
const parentGone = () => {
|
|
159
|
+
if (process.ppid !== PARENT) return true // reparented: the parent exited
|
|
160
|
+
try { process.kill(PARENT, 0); return false } catch (e) { return e.code !== 'EPERM' }
|
|
161
|
+
}
|
|
162
|
+
if (process.env.HIVE_AWAIT_KEEP_ALIVE !== '1' && PARENT > 1 && !parentGone()) {
|
|
163
|
+
setInterval(() => {
|
|
164
|
+
if (parentGone()) finish('parent-gone', { note: `the process that started this listener exited, so ${NAME} left the room` })
|
|
165
|
+
}, Number(process.env.HIVE_AWAIT_PARENT_CHECK_MS) || 15000).unref()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A REBUILT ROOM MUST NOT LEAVE THE LISTENER DEAF (7 Nov 2026, found by antigravity-2). A relay
|
|
169
|
+
// that restarts without its rooms (every deploy on Render's free plan) starts chat again from zero,
|
|
170
|
+
// but the cursor on disk still said, say, 300: scanning from 300 of a 3-message chat found nothing,
|
|
171
|
+
// so the agent showed LISTENING and no mention woke it until the idle timeout, which then moved the
|
|
172
|
+
// cursor past everything it had missed. So the cursor records which room it was counted in (its
|
|
173
|
+
// first message) and is ignored when that differs, or when it points past the end -- the rule the
|
|
174
|
+
// room's cursor above and the MCP's catch-up already follow. Chat is never trimmed, so within one
|
|
175
|
+
// room the first message never changes. An older cursor file without `head` is only clamped.
|
|
176
|
+
// (tests/await-rebuilt-room-test.mjs)
|
|
177
|
+
const cursorFile = path.join(dir, `.await-cursor-${room}.json`)
|
|
178
|
+
const headOf = (msgs) => { const m = msgs[0]; return m ? `${m.by}|${m.at}|${String(m.text || '').slice(0, 80)}` : '' }
|
|
179
|
+
const readCursor = (msgs) => {
|
|
180
|
+
try {
|
|
181
|
+
const c = JSON.parse(fs.readFileSync(cursorFile, 'utf8'))
|
|
182
|
+
const at = Number(c.at) || 0
|
|
183
|
+
if (at > msgs.length) return 0
|
|
184
|
+
if (typeof c.head === 'string' && c.head && c.head !== headOf(msgs)) return 0
|
|
185
|
+
return at
|
|
186
|
+
} catch { return 0 }
|
|
187
|
+
}
|
|
188
|
+
const writeCursor = (n, msgs) => { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cursorFile, JSON.stringify({ at: n, head: headOf(msgs) }), 'utf8') } catch { /* a cursor we cannot persist must not break the wait */ } }
|
|
189
|
+
|
|
190
|
+
function check() {
|
|
191
|
+
if (superseded()) return // the newer listener delivers it; consuming it here would hide it from that one
|
|
192
|
+
const msgs = chat.toArray()
|
|
193
|
+
let lastMine = -1
|
|
194
|
+
for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].by === NAME) { lastMine = i; break } }
|
|
195
|
+
const from = Math.max(readCursor(msgs), lastMine + 1, roomCursor(msgs.length))
|
|
196
|
+
const hits = []
|
|
197
|
+
for (let i = from; i < msgs.length; i++) {
|
|
198
|
+
const m = msgs[i]
|
|
199
|
+
if (rx.test(String(m.text || '')) && m.by !== NAME) hits.push(m)
|
|
200
|
+
}
|
|
201
|
+
// Advance past everything READ, not just everything matched — an unaddressed message I have
|
|
202
|
+
// already seen must not be re-scanned on the next call either.
|
|
203
|
+
if (hits.length) {
|
|
204
|
+
writeCursor(msgs.length, msgs)
|
|
205
|
+
publish(msgs.length)
|
|
206
|
+
// THE ORDER TO START AGAIN RIDES ON EVERY WAKE. It used to be said once, on the agent's first
|
|
207
|
+
// quiet hive_wait, and an agent had to remember it after every wake; agents forget (4 Oct 2026).
|
|
208
|
+
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)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
hive.provider.on('sync', (s) => {
|
|
213
|
+
if (!s) return
|
|
214
|
+
if (!synced) { synced = true; beat() }
|
|
215
|
+
if (!armed) {
|
|
216
|
+
armed = true
|
|
217
|
+
console.error(`[await] ${NAME} listening in ${room}. In room: ${hive.members().map((m) => m.name).join(', ')}`)
|
|
218
|
+
chat.observe(check)
|
|
219
|
+
}
|
|
220
|
+
check() // also on every resync — mentions may have landed while the socket was down
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// An idle timeout also advances the cursor. Without this, a quiet window followed by a loud one
|
|
224
|
+
// would re-deliver everything that arrived during the quiet window too.
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
try { writeCursor(chat.length, chat.toArray()) } catch {}
|
|
227
|
+
publish(chat.length) // nothing in the window named us, so nothing in it is mail the MCP should raise
|
|
228
|
+
finish('idle', { note: `nothing addressed to ${NAME} in ${IDLE_MIN} min. Run this same command again in the background to keep listening: ${AGAIN}` }, 1500)
|
|
229
|
+
}, Number(IDLE_MIN) * 60 * 1000)
|
|
230
|
+
process.on('SIGINT', () => { try { hive.stop() } catch {}; process.exit(0) })
|