hivecode-mcp 0.14.0 → 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 -450
  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 +2004 -1944
  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 -580
  27. package/hive-mcp.js +2156 -2093
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -350
  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 -230
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -209
  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 -558
  45. package/hive-slack-agent.js +408 -396
  46. package/hive-slack.js +2384 -1809
  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 -353
  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 +192 -192
  66. package/reconcile.cjs +621 -614
  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 +5189 -4994
  72. package/token.js +201 -201
package/hive-notify.js CHANGED
@@ -1,233 +1,234 @@
1
- // hive-notify -- tell a person, on Slack or Discord, when an agent in a room is waiting on them.
2
- //
3
- // npx -y hivecode-mcp@latest notify "<link>" --webhook <url> --for <name>[,<name>] [--every <minutes>] [--title <text>]
4
- //
5
- // WHY. On 10 Oct 2026 a two-agent demo stalled for an hour: one agent waited for its person's OK,
6
- // the other waited for it, and the person was not looking at the chat. The room knew. Nobody was
7
- // told. An owner who leaves agents working has to hear about the two things only they can unblock:
8
- //
9
- // - a task waiting for their approval sent at once: an agent is stopped until they answer
10
- // - messages from agents naming them batched, at most one post per --every minutes
11
- //
12
- // Neither is sent while that person is in the room (the Control Room counts): they can see it.
13
- //
14
- // WHAT IS NEVER SENT: the room's link or full id. On an open relay the id is the key to the room's
15
- // files, and a Slack channel is not the place for a key. Messages name the room by --title, or by
16
- // the first characters of its id.
17
- //
18
- // IT MUST NOT LOOK ALIVE AND DELIVER NOTHING. It posts once when it starts and refuses to run if
19
- // that post fails; it says once when it has lost the room and once when it is back.
20
- //
21
- // Error codes Q640-Q649 are this module's.
22
-
23
- import fs from 'fs'
24
- import os from 'os'
25
- import path from 'path'
26
- import crypto from 'crypto'
27
- import { startSync, parseLink } from './sync.js'
28
- import { makeNotifier, scrub } from './hive-webhook.js'
29
-
30
- const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
31
- const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
- const LOST_AFTER = 5 * 60000
33
-
34
- /** Does `text` address `name` (@Name, not part of a longer word)? @all counts. */
35
- export function addresses(text, name) {
36
- const t = String(text || '')
37
- if (/(^|[^\w@])@all\b/i.test(t)) return true
38
- return new RegExp(`(^|[^\\w@])@${esc(name)}(?![\\w-])`, 'i').test(t)
39
- }
40
-
41
- /** What has been sent, kept on disk so a restart does not send it again. */
42
- export function stateFileFor(room, home = os.homedir()) {
43
- const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
44
- return path.join(home, '.hivecode', 'notify', `${h}.json`)
45
- }
46
- export function lockFileFor(room, home = os.homedir()) {
47
- const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
48
- return path.join(home, '.hivecode', 'notify', `${h}.lock`)
49
- }
50
- const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
51
-
52
- /** One notifier per room. Created exclusively so two starting at the same time cannot double-post. */
53
- export function takeLock(file) {
54
- fs.mkdirSync(path.dirname(file), { recursive: true })
55
- for (let i = 0; i < 2; i++) {
56
- try {
57
- const fd = fs.openSync(file, 'wx')
58
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
59
- fs.closeSync(fd)
60
- return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === process.pid) fs.unlinkSync(file) } catch { } }
61
- } catch (e) {
62
- if (e.code !== 'EEXIST') throw e
63
- let held = null
64
- try { held = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { held = null }
65
- if (held && held.pid && held.pid !== process.pid && alive(held.pid)) {
66
- const err = new Error(`Q644 a notifier is already running for this room (process ${held.pid}, since ${held.at}). Use that one, or stop it first.`)
67
- err.code = 'Q644'
68
- throw err
69
- }
70
- try { fs.unlinkSync(file) } catch { }
71
- }
72
- }
73
- const err = new Error(`Q644 could not take ${file}`)
74
- err.code = 'Q644'
75
- throw err
76
- }
77
- function loadSent(file) {
78
- try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && j.tasks) return j } catch { }
79
- return { tasks: {} }
80
- }
81
- function saveSent(file, s) {
82
- try {
83
- fs.mkdirSync(path.dirname(file), { recursive: true })
84
- const ids = Object.keys(s.tasks)
85
- if (ids.length > 500) for (const id of ids.slice(0, ids.length - 500)) delete s.tasks[id]
86
- fs.writeFileSync(file, JSON.stringify(s))
87
- } catch { /* a notifier that cannot remember sends twice; it still sends */ }
88
- }
89
-
90
- /**
91
- * The watching itself, on a live sync handle. `check()` is one pass; the CLI calls it on a timer,
92
- * and tests call it with a clock of their own.
93
- */
94
- export function makeWatch({ hive, forNames, title, send, now = () => Date.now(), every = 5, stateFile, log = () => {} }) {
95
- const people = forNames.map((n) => String(n).trim()).filter(Boolean)
96
- const lc = new Set(people.map((n) => n.toLowerCase()))
97
- const tasks = hive.doc.getMap('tasks')
98
- const owners = hive.doc.getMap('owners')
99
- const chat = hive.doc.getArray('chat')
100
- const sent = loadSent(stateFile)
101
- let seenChat = chat.length // what was said before this started is not news
102
- const buffer = []
103
- let lastFlush = 0
104
- let lostSince = 0
105
- let toldLost = false
106
-
107
- const here = (name) => (hive.members ? hive.members() : []).some((m) => String(m.name).toLowerCase() === name.toLowerCase() && m.kind === 'human')
108
- const ownerOf = (t) => String(owners.get(t.to) || '')
109
-
110
- function waiting() {
111
- return [...tasks.values()].filter((t) => t && t.status === 'pending' && lc.has(ownerOf(t).toLowerCase()))
112
- }
113
-
114
- async function check() {
115
- const t = now()
116
- // THE CONNECTION. Lost for five minutes is said once; back is said once.
117
- const connected = !!(hive.connection ? hive.connection().connected : hive.provider && hive.provider.wsconnected)
118
- if (!connected) {
119
- if (!lostSince) lostSince = t
120
- if (!toldLost && t - lostSince >= LOST_AFTER) {
121
- toldLost = true
122
- await send(`Hivecode lost ${title} ${Math.round((t - lostSince) / 60000)} minutes ago, so nothing from it can reach you here. It keeps trying to reconnect.`)
123
- }
124
- return
125
- }
126
- if (lostSince) {
127
- if (toldLost) await send(`Hivecode is back in ${title}.`)
128
- lostSince = 0
129
- toldLost = false
130
- }
131
-
132
- // A TASK WAITING FOR APPROVAL: at once, once per task.
133
- for (const task of waiting()) {
134
- const owner = ownerOf(task)
135
- if (sent.tasks[task.id] || here(owner)) continue
136
- const ok = await send(`Approval needed in ${title}: ${task.by} asked ${task.to} to "${oneLine(task.text, 300)}". ${task.to} waits until ${owner} approves or denies it in the Control Room.`)
137
- if (ok) {
138
- sent.tasks[task.id] = t
139
- saveSent(stateFile, sent)
140
- }
141
- }
142
-
143
- // MESSAGES NAMING THEM, from agents, while they are away: batched.
144
- const all = chat.toArray()
145
- if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
146
- for (const m of all.slice(seenChat)) {
147
- if (!m || m.kind !== 'ai') continue
148
- for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
149
- }
150
- seenChat = all.length
151
- if (buffer.length && t - lastFlush >= every * 60000) {
152
- const shown = buffer.slice()
153
- const to = [...new Set(shown.map((b) => b.to))].join(', ')
154
- const lines = shown.slice(0, 5).map((b) => `- ${b.by}: ${oneLine(b.text, 220)}`)
155
- if (shown.length > 5) lines.push(`- and ${shown.length - 5} more`)
156
- const ok = await send(`${shown.length} message${shown.length === 1 ? '' : 's'} for ${to} in ${title}:\n${lines.join('\n')}`)
157
- if (ok) {
158
- buffer.splice(0, shown.length)
159
- lastFlush = t
160
- }
161
- }
162
- }
163
-
164
- return { check, waiting, buffered: () => buffer.length, sent }
165
- }
166
-
167
- // ---- CLI -------------------------------------------------------------------------------------------
168
-
169
- const USAGE = `usage:
170
- npx -y hivecode-mcp@latest notify "<link>" --webhook <Slack or Discord webhook URL> --for <name>[,<name>]
171
-
172
- --for <names> whose approvals and mentions to send (the people, not their agents)
173
- --every <min> at most one post of messages per this many minutes (default 5)
174
- --title <text> what to call the room in posts (default: the first characters of its id)
175
- --name <name> the notifier's own name in the room (default Notifier)`
176
-
177
- export async function main(argv, { log = console.log, logErr = console.error, fetchImpl = globalThis.fetch, home = os.homedir(), now = () => Date.now(), once = false } = {}) {
178
- if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { log(USAGE); return 0 }
179
- const SWITCHES = new Set(['dry-run', 'once'])
180
- const pos = []
181
- const f = {}
182
- for (let i = 0; i < argv.length; i++) {
183
- const a = argv[i]
184
- if (a.startsWith('--')) {
185
- const k = a.slice(2)
186
- if (SWITCHES.has(k)) f[k] = true
187
- else f[k] = argv[++i] ?? ''
188
- } else {
189
- pos.push(a)
190
- }
191
- }
192
- const link = pos[0] || ''
193
- const { relay, room, token } = parseLink(link)
194
- if (!relay || !room) { logErr(`Q640 "${link}" is not a room link (it looks like wss://relay|room-...).\n\n${USAGE}`); return 2 }
195
- if (!f.webhook) { logErr(`Q641 say where to post, with --webhook <Slack or Discord incoming-webhook URL>.\n\n${USAGE}`); return 2 }
196
- const forNames = String(f.for || '').split(',').map((s) => s.trim()).filter(Boolean)
197
- if (!forNames.length) { logErr(`Q642 say whose waits to report, with --for <name> (the person, as they appear in the room).\n\n${USAGE}`); return 2 }
198
- const every = Number(f.every || 5)
199
- if (!(every > 0)) { logErr('Q642 --every must be a positive number of minutes'); return 2 }
200
- const title = f.title ? oneLine(f.title, 60) : `room ${room.slice(0, 8)}…`
201
- const send = makeNotifier(f.webhook, { fetchImpl, log })
202
-
203
- // FIRST, PROVE THE WEBHOOK WORKS. A notifier whose posts go nowhere is worse than none: the person
204
- // believes they will be told.
205
- if (send.kind === 'other' && send.host) log(`posting in Slack format to ${send.host}`)
206
- const okSent = await send(`Hivecode will post here when an agent in ${title} is waiting on ${forNames.join(', ')}.`)
207
- if (!okSent) { logErr('Q643 the webhook refused the first post (see above). Check the URL, then start again. Nothing is running.'); return 1 }
208
-
209
- const lockFile = lockFileFor(room, home)
210
- const releaseLock = (() => { try { return takeLock(lockFile) } catch (e) { logErr(e.message); return null } })()
211
- if (!releaseLock) return 2
212
-
213
- const dir = path.join(home, '.hivecode', 'notify', `room-${crypto.createHash('sha256').update(room).digest('hex').slice(0, 12)}`)
214
- const hive = startSync({ relay, room, dir, name: f.name || 'Notifier', kind: 'ai', token: token || process.env.HIVE_TOKEN || '', syncFiles: false, passive: true, log: () => { } })
215
- const w = makeWatch({ hive, forNames, title, send, now, every, stateFile: stateFileFor(room, home), log })
216
- let synced = false
217
- await new Promise((res) => { const t = setTimeout(res, 15000); hive.provider.on('sync', (s) => { if (s) { synced = true; clearTimeout(t); res() } }) })
218
- if (!synced && !(hive.provider && hive.provider.wsconnected)) {
219
- logErr(`NOTE: could not connect to relay at ${relay}. The room may be unreachable or the network down.`)
220
- }
221
- const members = (hive.members() || []).filter((m) => m.name !== (f.name || 'Notifier')).map((m) => `${m.name} (${m.kind})`)
222
- const pending = w.waiting().length
223
- log(`watching ${title}: ${members.length ? members.join(', ') : 'nobody here yet'}; ${pending} approval(s) waiting now. Ctrl+C stops it.`)
224
- if (!members.length && !hive.doc.getArray('chat').length) log('NOTE: this room is empty -- no members and no chat. Is the link right?')
225
- if (once || f['dry-run']) { await w.check(); hive.stop(); releaseLock(); return 0 }
226
- const timer = setInterval(() => { w.check().catch((e) => log(`check failed: ${e.message}`)) }, 15000)
227
- const quit = () => { clearInterval(timer); try { hive.stop() } catch { } try { releaseLock() } catch { } process.exit(0) }
228
- process.on('SIGINT', quit)
229
- process.on('SIGTERM', quit)
230
- await w.check()
231
- return new Promise(() => { }) // runs until stopped
232
- }
233
-
1
+ // hive-notify -- tell a person, on Slack or Discord, when an agent in a room is waiting on them.
2
+ //
3
+ // npx -y hivecode-mcp@latest notify "<link>" --webhook <url> --for <name>[,<name>] [--every <minutes>] [--title <text>]
4
+ //
5
+ // WHY. On 10 Oct 2026 a two-agent demo stalled for an hour: one agent waited for its person's OK,
6
+ // the other waited for it, and the person was not looking at the chat. The room knew. Nobody was
7
+ // told. An owner who leaves agents working has to hear about the two things only they can unblock:
8
+ //
9
+ // - a task waiting for their approval sent at once: an agent is stopped until they answer
10
+ // - messages from agents naming them batched, at most one post per --every minutes
11
+ //
12
+ // Neither is sent while that person is in the room (the Control Room counts): they can see it.
13
+ //
14
+ // WHAT IS NEVER SENT: the room's link or full id. On an open relay the id is the key to the room's
15
+ // files, and a Slack channel is not the place for a key. Messages name the room by --title, or by
16
+ // the first characters of its id.
17
+ //
18
+ // IT MUST NOT LOOK ALIVE AND DELIVER NOTHING. It posts once when it starts and refuses to run if
19
+ // that post fails; it says once when it has lost the room and once when it is back.
20
+ //
21
+ // Error codes Q640-Q649 are this module's.
22
+
23
+ import fs from 'fs'
24
+ import os from 'os'
25
+ import path from 'path'
26
+ import crypto from 'crypto'
27
+ import { startSync, parseLink } from './sync.js'
28
+ import { makeNotifier, scrub } from './hive-webhook.js'
29
+ import { relayFetch } from './hive-transport.js'
30
+
31
+ const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
32
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
33
+ const LOST_AFTER = 5 * 60000
34
+
35
+ /** Does `text` address `name` (@Name, not part of a longer word)? @all counts. */
36
+ export function addresses(text, name) {
37
+ const t = String(text || '')
38
+ if (/(^|[^\w@])@all\b/i.test(t)) return true
39
+ return new RegExp(`(^|[^\\w@])@${esc(name)}(?![\\w-])`, 'i').test(t)
40
+ }
41
+
42
+ /** What has been sent, kept on disk so a restart does not send it again. */
43
+ export function stateFileFor(room, home = os.homedir()) {
44
+ const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
45
+ return path.join(home, '.hivecode', 'notify', `${h}.json`)
46
+ }
47
+ export function lockFileFor(room, home = os.homedir()) {
48
+ const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
49
+ return path.join(home, '.hivecode', 'notify', `${h}.lock`)
50
+ }
51
+ const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
52
+
53
+ /** One notifier per room. Created exclusively so two starting at the same time cannot double-post. */
54
+ export function takeLock(file) {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true })
56
+ for (let i = 0; i < 2; i++) {
57
+ try {
58
+ const fd = fs.openSync(file, 'wx')
59
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
60
+ fs.closeSync(fd)
61
+ return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === process.pid) fs.unlinkSync(file) } catch { } }
62
+ } catch (e) {
63
+ if (e.code !== 'EEXIST') throw e
64
+ let held = null
65
+ try { held = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { held = null }
66
+ if (held && held.pid && held.pid !== process.pid && alive(held.pid)) {
67
+ const err = new Error(`Q644 a notifier is already running for this room (process ${held.pid}, since ${held.at}). Use that one, or stop it first.`)
68
+ err.code = 'Q644'
69
+ throw err
70
+ }
71
+ try { fs.unlinkSync(file) } catch { }
72
+ }
73
+ }
74
+ const err = new Error(`Q644 could not take ${file}`)
75
+ err.code = 'Q644'
76
+ throw err
77
+ }
78
+ function loadSent(file) {
79
+ try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && j.tasks) return j } catch { }
80
+ return { tasks: {} }
81
+ }
82
+ function saveSent(file, s) {
83
+ try {
84
+ fs.mkdirSync(path.dirname(file), { recursive: true })
85
+ const ids = Object.keys(s.tasks)
86
+ if (ids.length > 500) for (const id of ids.slice(0, ids.length - 500)) delete s.tasks[id]
87
+ fs.writeFileSync(file, JSON.stringify(s))
88
+ } catch { /* a notifier that cannot remember sends twice; it still sends */ }
89
+ }
90
+
91
+ /**
92
+ * The watching itself, on a live sync handle. `check()` is one pass; the CLI calls it on a timer,
93
+ * and tests call it with a clock of their own.
94
+ */
95
+ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(), every = 5, stateFile, log = () => {} }) {
96
+ const people = forNames.map((n) => String(n).trim()).filter(Boolean)
97
+ const lc = new Set(people.map((n) => n.toLowerCase()))
98
+ const tasks = hive.doc.getMap('tasks')
99
+ const owners = hive.doc.getMap('owners')
100
+ const chat = hive.doc.getArray('chat')
101
+ const sent = loadSent(stateFile)
102
+ let seenChat = chat.length // what was said before this started is not news
103
+ const buffer = []
104
+ let lastFlush = 0
105
+ let lostSince = 0
106
+ let toldLost = false
107
+
108
+ const here = (name) => (hive.members ? hive.members() : []).some((m) => String(m.name).toLowerCase() === name.toLowerCase() && m.kind === 'human')
109
+ const ownerOf = (t) => String(owners.get(t.to) || '')
110
+
111
+ function waiting() {
112
+ return [...tasks.values()].filter((t) => t && t.status === 'pending' && lc.has(ownerOf(t).toLowerCase()))
113
+ }
114
+
115
+ async function check() {
116
+ const t = now()
117
+ // THE CONNECTION. Lost for five minutes is said once; back is said once.
118
+ const connected = !!(hive.connection ? hive.connection().connected : hive.provider && hive.provider.wsconnected)
119
+ if (!connected) {
120
+ if (!lostSince) lostSince = t
121
+ if (!toldLost && t - lostSince >= LOST_AFTER) {
122
+ toldLost = true
123
+ await send(`Hivecode lost ${title} ${Math.round((t - lostSince) / 60000)} minutes ago, so nothing from it can reach you here. It keeps trying to reconnect.`)
124
+ }
125
+ return
126
+ }
127
+ if (lostSince) {
128
+ if (toldLost) await send(`Hivecode is back in ${title}.`)
129
+ lostSince = 0
130
+ toldLost = false
131
+ }
132
+
133
+ // A TASK WAITING FOR APPROVAL: at once, once per task.
134
+ for (const task of waiting()) {
135
+ const owner = ownerOf(task)
136
+ if (sent.tasks[task.id] || here(owner)) continue
137
+ const ok = await send(`Approval needed in ${title}: ${task.by} asked ${task.to} to "${oneLine(task.text, 300)}". ${task.to} waits until ${owner} approves or denies it in the Control Room.`)
138
+ if (ok) {
139
+ sent.tasks[task.id] = t
140
+ saveSent(stateFile, sent)
141
+ }
142
+ }
143
+
144
+ // MESSAGES NAMING THEM, from agents, while they are away: batched.
145
+ const all = chat.toArray()
146
+ if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
147
+ for (const m of all.slice(seenChat)) {
148
+ if (!m || m.kind !== 'ai') continue
149
+ for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
150
+ }
151
+ seenChat = all.length
152
+ if (buffer.length && t - lastFlush >= every * 60000) {
153
+ const shown = buffer.slice()
154
+ const to = [...new Set(shown.map((b) => b.to))].join(', ')
155
+ const lines = shown.slice(0, 5).map((b) => `- ${b.by}: ${oneLine(b.text, 220)}`)
156
+ if (shown.length > 5) lines.push(`- and ${shown.length - 5} more`)
157
+ const ok = await send(`${shown.length} message${shown.length === 1 ? '' : 's'} for ${to} in ${title}:\n${lines.join('\n')}`)
158
+ if (ok) {
159
+ buffer.splice(0, shown.length)
160
+ lastFlush = t
161
+ }
162
+ }
163
+ }
164
+
165
+ return { check, waiting, buffered: () => buffer.length, sent }
166
+ }
167
+
168
+ // ---- CLI -------------------------------------------------------------------------------------------
169
+
170
+ const USAGE = `usage:
171
+ npx -y hivecode-mcp@latest notify "<link>" --webhook <Slack or Discord webhook URL> --for <name>[,<name>]
172
+
173
+ --for <names> whose approvals and mentions to send (the people, not their agents)
174
+ --every <min> at most one post of messages per this many minutes (default 5)
175
+ --title <text> what to call the room in posts (default: the first characters of its id)
176
+ --name <name> the notifier's own name in the room (default Notifier)`
177
+
178
+ export async function main(argv, { log = console.log, logErr = console.error, fetchImpl = relayFetch, home = os.homedir(), now = () => Date.now(), once = false } = {}) {
179
+ if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { log(USAGE); return 0 }
180
+ const SWITCHES = new Set(['dry-run', 'once'])
181
+ const pos = []
182
+ const f = {}
183
+ for (let i = 0; i < argv.length; i++) {
184
+ const a = argv[i]
185
+ if (a.startsWith('--')) {
186
+ const k = a.slice(2)
187
+ if (SWITCHES.has(k)) f[k] = true
188
+ else f[k] = argv[++i] ?? ''
189
+ } else {
190
+ pos.push(a)
191
+ }
192
+ }
193
+ const link = pos[0] || ''
194
+ const { relay, room, token } = parseLink(link)
195
+ if (!relay || !room) { logErr(`Q640 "${link}" is not a room link (it looks like wss://relay|room-...).\n\n${USAGE}`); return 2 }
196
+ if (!f.webhook) { logErr(`Q641 say where to post, with --webhook <Slack or Discord incoming-webhook URL>.\n\n${USAGE}`); return 2 }
197
+ const forNames = String(f.for || '').split(',').map((s) => s.trim()).filter(Boolean)
198
+ if (!forNames.length) { logErr(`Q642 say whose waits to report, with --for <name> (the person, as they appear in the room).\n\n${USAGE}`); return 2 }
199
+ const every = Number(f.every || 5)
200
+ if (!(every > 0)) { logErr('Q642 --every must be a positive number of minutes'); return 2 }
201
+ const title = f.title ? oneLine(f.title, 60) : `room ${room.slice(0, 8)}…`
202
+ const send = makeNotifier(f.webhook, { fetchImpl, log })
203
+
204
+ // FIRST, PROVE THE WEBHOOK WORKS. A notifier whose posts go nowhere is worse than none: the person
205
+ // believes they will be told.
206
+ if (send.kind === 'other' && send.host) log(`posting in Slack format to ${send.host}`)
207
+ const okSent = await send(`Hivecode will post here when an agent in ${title} is waiting on ${forNames.join(', ')}.`)
208
+ if (!okSent) { logErr('Q643 the webhook refused the first post (see above). Check the URL, then start again. Nothing is running.'); return 1 }
209
+
210
+ const lockFile = lockFileFor(room, home)
211
+ const releaseLock = (() => { try { return takeLock(lockFile) } catch (e) { logErr(e.message); return null } })()
212
+ if (!releaseLock) return 2
213
+
214
+ const dir = path.join(home, '.hivecode', 'notify', `room-${crypto.createHash('sha256').update(room).digest('hex').slice(0, 12)}`)
215
+ const hive = startSync({ relay, room, dir, name: f.name || 'Notifier', kind: 'ai', token: token || process.env.HIVE_TOKEN || '', syncFiles: false, passive: true, log: () => { } })
216
+ const w = makeWatch({ hive, forNames, title, send, now, every, stateFile: stateFileFor(room, home), log })
217
+ let synced = false
218
+ await new Promise((res) => { const t = setTimeout(res, 15000); hive.provider.on('sync', (s) => { if (s) { synced = true; clearTimeout(t); res() } }) })
219
+ if (!synced && !(hive.provider && hive.provider.wsconnected)) {
220
+ logErr(`NOTE: could not connect to relay at ${relay}. The room may be unreachable or the network down.`)
221
+ }
222
+ const members = (hive.members() || []).filter((m) => m.name !== (f.name || 'Notifier')).map((m) => `${m.name} (${m.kind})`)
223
+ const pending = w.waiting().length
224
+ log(`watching ${title}: ${members.length ? members.join(', ') : 'nobody here yet'}; ${pending} approval(s) waiting now. Ctrl+C stops it.`)
225
+ if (!members.length && !hive.doc.getArray('chat').length) log('NOTE: this room is empty -- no members and no chat. Is the link right?')
226
+ if (once || f['dry-run']) { await w.check(); hive.stop(); releaseLock(); return 0 }
227
+ const timer = setInterval(() => { w.check().catch((e) => log(`check failed: ${e.message}`)) }, 15000)
228
+ const quit = () => { clearInterval(timer); try { hive.stop() } catch { } try { releaseLock() } catch { } process.exit(0) }
229
+ process.on('SIGINT', quit)
230
+ process.on('SIGTERM', quit)
231
+ await w.check()
232
+ return new Promise(() => { }) // runs until stopped
233
+ }
234
+