hivecode-mcp 0.14.1 → 0.14.2

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.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. 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
- export function authorsOf(receipts, verify, roomText) {
18
- const ok = (receipts || []).filter((r) => r && r.author && isReceiptValid(verify, r))
19
- if (!ok.length) return { last: null, contributors: [], first: 0, receipts: [] }
20
- const want = typeof roomText === 'string' ? contentHash(roomText) : null
21
- let idx = -1
22
- if (want) {
23
- for (let i = ok.length - 1; i >= 0; i--) {
24
- if (ok[i].contentHash === want) {
25
- idx = i
26
- break
27
- }
28
- }
29
- }
30
- const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
31
- const last = held[held.length - 1]
32
- const seen = new Map()
33
- for (const r of held) {
34
- if (!seen.has(r.author)) {
35
- seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
36
- }
37
- }
38
- return {
39
- last: last ? { author: last.author, name: last.name || 'agent', at: last.at || 0 } : null,
40
- contributors: [...seen.values()],
41
- first: Math.min(...ok.map((r) => r.at || 0)),
42
- receipts: held,
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) })