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
@@ -1,396 +1,408 @@
1
- // hive-slack-agent — the room, rendered onto Slack's agent surface.
2
- //
3
- // NAMED hive-slack-agent, NOT hive-agent: hive-agent.js already exists and is the headless join
4
- // client an AI runs to enter a room at all. This file is a Slack bridge and has nothing to do
5
- // with that one.
6
- //
7
- // node hive-slack-agent.js "<relay|room>" --channel D123 [--name Hivecode] [--allow <U123,U456>]
8
- // env: SLACK_BOT_TOKEN (xoxb-…), SLACK_APP_TOKEN (xapp-…), optional SLACK_ALLOW, HIVE_SLACK_API
9
- //
10
- // WHY THIS AND NOT THE FEED. hive-slack.js posts what HAPPENED into a channel thread: the
11
- // team's record of the night, read in the morning. This is the other half -- the panel one
12
- // person opens to see what is happening RIGHT NOW, and to stop it. Both are needed and
13
- // neither replaces the other.
14
- //
15
- // THE STOP BUTTON IS THE PRODUCT. Everything else here is decoration around one guarantee:
16
- // somebody who is not at their desk can halt every agent in a room from their phone. Slack
17
- // renders that button natively for any app subscribed to `agent_session_stopped`, so the
18
- // control panel THE SECOND SHIFT needs is a MAPPING job, not a front-end project. That is the
19
- // whole reason this file is short.
20
- //
21
- // WHAT WE DO NOT PRETEND. The pause is cooperative: control() writes 'paused' into the room and
22
- // hive_wait refuses to hand that agent new work. An agent mid-step finishes its step. Calling
23
- // that a kill switch would be a stronger claim than the mechanism supports, and the first
24
- // person to test it would find out. It stops the NEXT thing, reliably, and that is what makes
25
- // it safe to walk away.
26
-
27
- import WebSocket from 'ws'
28
- import { startSync, parseLink } from './sync.js'
29
- import {
30
- makeQueue,
31
- slackPost,
32
- stopAll,
33
- resumeAll,
34
- resolveName,
35
- route,
36
- makeSlackActorCheck,
37
- formatSlackSender,
38
- checkAgentScopes,
39
- preflightSocketMode,
40
- connectSocketMode,
41
- parseSlackAllow,
42
- } from './hive-slack.js'
43
-
44
- export { stopAll, resumeAll, resolveName, route }
45
-
46
- // Slack's plan block knows four states. The room knows four task statuses. They are not the
47
- // same four, and the join is the only interesting decision in this file.
48
- //
49
- // pending -> pending waiting on a human owner to approve it
50
- // accepted -> in_progress an agent has it and may work
51
- // done -> completed
52
- // denied -> error the owner said no; that IS the terminal state, not a failure
53
- //
54
- // A PAUSED assignee overrides 'accepted' back to 'pending'. Slack has no paused state, and
55
- // showing a spinner next to an agent that has been halted is a lie told by the exact panel
56
- // somebody is trusting to tell them whether they can go to bed. The title carries the reason
57
- // so it can never be confused with the approval-pending case.
58
- export const STATE_OF = { pending: 'pending', accepted: 'in_progress', done: 'completed', denied: 'error' }
59
-
60
- export function taskState(t, paused) {
61
- const base = STATE_OF[t.status] || 'pending'
62
- return base === 'in_progress' && paused ? 'pending' : base
63
- }
64
-
65
- export function taskTitle(t, paused) {
66
- const who = t.to || 'unassigned'
67
- if (paused) return `${who} — ${t.text} (paused)`
68
- if (t.status === 'pending') return `${who} — ${t.text} (awaiting approval)`
69
- return `${who} — ${t.text}`
70
- }
71
-
72
- /** Read the live room into a plain object. Kept separate from rendering so the renderer can be
73
- * tested without a relay, and so one read is used for the whole frame -- sampling the CRDT
74
- * per-task would let the panel show two different moments in the same picture. */
75
- export function snapshot(hive) {
76
- const controls = hive.doc.getMap('controls')
77
- const tasks = [...hive.doc.getMap('tasks').values()]
78
- const pausedOf = (who) => { const c = controls.get(who); return !!(c && c.state === 'paused') }
79
- const people = (hive.members ? hive.members() : []) || []
80
- return {
81
- tasks: tasks.sort((a, b) => String(a.at).localeCompare(String(b.at))),
82
- people: people.map((p) => ({ ...p, paused: pausedOf(p.name) })),
83
- pausedOf,
84
- open: tasks.filter((t) => t.status === 'accepted').length,
85
- waiting: tasks.filter((t) => t.status === 'pending').length,
86
- }
87
- }
88
-
89
- /** The room as Slack streaming chunks: a one-line summary, then one task card per task. */
90
- export function planChunks(snap) {
91
- const live = snap.people.filter((p) => !p.paused).length
92
- const head = snap.people.length
93
- ? `*${live}/${snap.people.length} running* · ${snap.open} in progress · ${snap.waiting} awaiting approval`
94
- : '_nobody is in the room_'
95
- const chunks = [{ type: 'markdown_text', markdown_text: head }]
96
- for (const t of snap.tasks) {
97
- const paused = snap.pausedOf(t.to)
98
- chunks.push({
99
- type: 'task_update',
100
- task: { task_id: String(t.id), title: taskTitle(t, paused), status: taskState(t, paused) },
101
- })
102
- }
103
- return chunks
104
- }
105
-
106
- // stopAll, resumeAll, resolveName, and route live in hive-slack.js so both the channel thread
107
- // bridge and this Slack agent surface share the exact same deterministic router and case-insensitive
108
- // participant lookup without duplicating control logic.
109
-
110
- /** WHO MAY ACT, AND FROM WHERE. This surface used to act for anybody who could type at it: no guest
111
- * refusal, no --allow, and main() handed it every `message` event the app hears -- which, with
112
- * message.channels and message.groups in the manifest, is every channel the bot is in. Now it is
113
- * the same rules as `slack start` (makeSlackActorCheck, one place in hive-slack.js):
114
- * - stop / stop all / the native stop button: any full workspace member; if Slack cannot say who
115
- * they are, it stops anyway and says so (stopping is the safe direction);
116
- * - resume: only user ids in `allow`;
117
- * - guests and users from other workspaces: refused for everything, status included, so they are
118
- * never shown the room's tasks.
119
- * A typed message counts only in the configured channel or in a DM (each user has their own
120
- * assistant DM, so any `im` channel is a valid place, and the answer goes to THAT DM). */
121
- export function makeAgent({
122
- hive, channel, token, post, botName = 'Hivecode', setStatus,
123
- teamId = '', allow = [], botUserId = '', lookupUser = null, actorCheck = null, api, fetchImpl,
124
- }) {
125
- const queue = makeQueue({ send: (m) => post(m) })
126
- const status = setStatus || ((body) => slackPost('agents.sessions.setStatus', body, token))
127
- const allowSet = new Set((Array.isArray(allow) ? allow : String(allow || '').split(',')).map((s) => String(s || '').trim()).filter(Boolean))
128
- const actors = actorCheck || makeSlackActorCheck({ token, teamId, lookupUser, ...(api ? { api } : {}), ...(fetchImpl ? { fetchImpl } : {}) })
129
-
130
- /** Push the current room onto the surface as one streamed message. */
131
- async function frame(thread_ts, closing = 'Ask me to stop anything.', ch = channel) {
132
- const chunks = planChunks(snapshot(hive))
133
- const started = await queue.push({
134
- method: 'chat.startStream', channel: ch, thread_ts, task_display_mode: 'plan', chunks: chunks.slice(0, 1),
135
- })
136
- const message_ts = started && started.ts
137
- if (chunks.length > 1) {
138
- await queue.push({ method: 'chat.appendStream', channel: ch, message_ts, thread_ts, chunks: chunks.slice(1) })
139
- }
140
- await queue.push({
141
- method: 'chat.stopStream', channel: ch, message_ts, thread_ts,
142
- chunks: [{ type: 'markdown_text', markdown_text: closing }],
143
- })
144
- return message_ts
145
- }
146
-
147
- // The loading state does NOT clear itself when a message is posted -- Slack leaves the
148
- // session in `processing` for a full hour if nobody says otherwise. So every path that
149
- // sets it must clear it, which is why this lives in a finally.
150
- async function busy(thread_ts, on, ch = channel) {
151
- try { await status({ status: on ? 'processing' : 'active', channel_id: ch, thread_ts }) } catch { /* the panel still works */ }
152
- }
153
-
154
- /** A plain reply in the thread. Used for refusals, which must not stream the room's tasks. */
155
- async function say(thread_ts, text, ch = channel) {
156
- try { await queue.push({ method: 'chat.postMessage', channel: ch, thread_ts, text }) } catch { /* nothing more to do */ }
157
- }
158
-
159
- /** May this user do this? -> { ok, by, unverified, reply } */
160
- async function authorize(user, intent) {
161
- const actor = await actors.resolve(user)
162
- if (intent === 'stop' || intent === 'stop-all') return actors.mayAct(actor, 'stop', allowSet)
163
- if (intent === 'resume' || intent === 'resume-all') return actors.mayAct(actor, 'resume', allowSet)
164
- // status and everything else: any verified full member. mayAct(..., 'reply') would demand
165
- // --allow, which is for acting on agents, not for reading what is running.
166
- if (actor && actor.ok) return { ok: true, by: formatSlackSender(actor.handle || actor.userId), unverified: false, reply: '' }
167
- return actors.mayAct(actor, 'reply', allowSet)
168
- }
169
-
170
- /** A human typed something at the agent. */
171
- async function onPrompt({ text, thread_ts, user, channel: ch = channel }) {
172
- const r = route(text)
173
- const check = await authorize(user, r.intent)
174
- if (!check.ok) {
175
- if (check.reply) await say(thread_ts, check.reply, ch)
176
- return { r, refused: check.refused || 'refused' }
177
- }
178
- const by = check.by || formatSlackSender(user)
179
- const note = check.unverified ? ` (${check.reply})` : ''
180
- await busy(thread_ts, true, ch)
181
- try {
182
- if (r.intent === 'stop-all') {
183
- const who = stopAll(hive, by)
184
- return { r, ts: await frame(thread_ts, who.length ? `:octagonal_sign: paused *${who.join('*, *')}*. Nothing new will be picked up.${note}` : 'No agents to pause.', ch) }
185
- }
186
- if (r.intent === 'resume-all') {
187
- const who = resumeAll(hive, by)
188
- return { r, ts: await frame(thread_ts, who.length ? `:arrow_forward: resumed *${who.join('*, *')}*.` : 'No agents to resume.', ch) }
189
- }
190
- if (r.intent === 'stop' || r.intent === 'resume') {
191
- const real = resolveName(hive, r.who)
192
- if (!real) {
193
- const here = (hive.members ? hive.members() : []).map((p) => p.name).join(', ') || 'nobody'
194
- return { r, ok: false, ts: await frame(thread_ts, `I do not see *${r.who}* in this room. Here right now: ${here}.`, ch) }
195
- }
196
- hive.control(real, r.intent === 'stop' ? 'paused' : 'running', by)
197
- return { r, ok: true, who: real, ts: await frame(thread_ts, `${r.intent === 'stop' ? ':octagonal_sign: paused' : ':arrow_forward: resumed'} *${real}*.${r.intent === 'stop' ? note : ''}`, ch) }
198
- }
199
- if (r.intent === 'status') return { r, ts: await frame(thread_ts, undefined, ch) }
200
- return { r, ts: await frame(thread_ts, `I can stop, resume and report. I did not act on "${r.text}".`, ch) }
201
- } finally {
202
- await busy(thread_ts, false, ch)
203
- }
204
- }
205
-
206
- /** Slack's native stop button. This is the one path that must never be clever. */
207
- async function onSessionStopped({ thread_ts, user, channel: ch = channel }) {
208
- const check = await authorize(user, 'stop-all')
209
- if (!check.ok) {
210
- if (check.reply) await say(thread_ts, check.reply, ch)
211
- await busy(thread_ts, false, ch)
212
- return []
213
- }
214
- const who = stopAll(hive, check.by || formatSlackSender(user))
215
- await busy(thread_ts, false, ch)
216
- return who
217
- }
218
-
219
- /** The one entry point for Slack events. Only a message in the configured channel or in a DM, or
220
- * the native stop button, does anything; everything else is ignored before any actor lookup. */
221
- async function onEvent(ev) {
222
- if (!ev || ev.bot_id || ev.subtype || (botUserId && ev.user === botUserId)) return { ignored: 'bot_or_subtype' }
223
- const targetChannel = ev.channel || channel
224
- const isDm = ev.channel_type === 'im' || (typeof targetChannel === 'string' && targetChannel.startsWith('D'))
225
- const here = targetChannel === channel || isDm
226
- if (!here) return { ignored: 'wrong_channel' }
227
- if (ev.type === 'agent_session_stopped') {
228
- const who = await onSessionStopped({ thread_ts: ev.thread_ts || ev.ts, user: ev.user, channel: targetChannel })
229
- return { action: 'session-stopped', stopped: who }
230
- }
231
- if (ev.type !== 'message' || !ev.text) return { ignored: 'unsupported_event' }
232
- return onPrompt({ text: ev.text, thread_ts: ev.thread_ts || ev.ts, user: ev.user, channel: targetChannel })
233
- }
234
-
235
- return { onPrompt, onSessionStopped, onEvent, frame, queue }
236
- }
237
-
238
- // Suggested prompts are declared once, in slack-app-manifest-agent.json
239
- // (features.assistant_view.suggested_prompts); there is no second copy in code to drift from it.
240
-
241
- /** Verify SLACK_BOT_TOKEN scopes (chat:write, assistant:write, im:history/channels:history/groups:history, users:read)
242
- * and SLACK_APP_TOKEN Socket Mode connection before starting hive-slack-agent (Q630, Q634). */
243
- export async function preflightSlackAgent({
244
- token = '',
245
- appToken = '',
246
- channel = '',
247
- api,
248
- fetchImpl = fetch,
249
- } = {}) {
250
- if (!token || !String(token).trim()) {
251
- return {
252
- ok: false,
253
- code: 'Q630',
254
- error: 'Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.',
255
- }
256
- }
257
- if (!appToken || !String(appToken).trim()) {
258
- return {
259
- ok: false,
260
- code: 'Q634',
261
- error: 'Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to start slack agent.',
262
- }
263
- }
264
- let auth
265
- try {
266
- auth = await slackPost('auth.test', {}, token, fetchImpl, api)
267
- } catch (e) {
268
- if (!e || !e.slackError) {
269
- const why = e.message || 'network error'
270
- return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
271
- }
272
- return {
273
- ok: false,
274
- code: 'Q630',
275
- error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${e.code || e.message || 'auth_failed'}). Check the token or reinstall the Slack app.`,
276
- }
277
- }
278
- const scopes = Array.isArray(auth && auth._scopes) ? auth._scopes : null
279
- const sc = checkAgentScopes({ scopes, channel })
280
- if (!sc.ok) return sc
281
- // Without the workspace id, users from OTHER workspaces (Slack Connect) cannot be told apart, so
282
- // nobody could be refused as an outsider. Refused here, as hive-github.js and hive-mcp-http.js do.
283
- if (!auth || !auth.team_id) {
284
- return { ok: false, code: 'Q634', error: 'Q634 Slack did not say which workspace the bot is in (auth.test returned no team_id), so users from other workspaces could not be refused. Reinstall the Slack app.' }
285
- }
286
- const sockPre = await preflightSocketMode({ appToken, api, fetchImpl })
287
- if (!sockPre.ok) return sockPre
288
- return {
289
- ok: true,
290
- url: sockPre.url,
291
- botUser: (auth && auth.user) || 'Hivecode',
292
- botUserId: (auth && auth.user_id) || '',
293
- teamId: (auth && auth.team_id) || '',
294
- scopes,
295
- }
296
- }
297
-
298
- // ---- main -------------------------------------------------------------------------------
299
- export const USAGE = `usage:
300
- SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "<relay|room>" --channel <id> [--name <name>] [--allow <U123,U456>]`
301
-
302
- export async function main(argv = process.argv.slice(2), {
303
- log = console.log,
304
- logErr = console.error,
305
- env = process.env,
306
- fetchImpl = fetch,
307
- WebSocketImpl = WebSocket,
308
- } = {}) {
309
- if (argv.includes('--help') || argv.includes('-h') || argv.includes('help')) {
310
- log(USAGE)
311
- return 0
312
- }
313
- const flag = (k, d = '') => { const i = argv.indexOf('--' + k); return i === -1 ? d : (argv[i + 1] || '') }
314
- const LINK = argv.find((a) => !a.startsWith('--')) || ''
315
- const CHANNEL = flag('channel')
316
- const TOKEN = env.SLACK_BOT_TOKEN || ''
317
- const APP_TOKEN = env.SLACK_APP_TOKEN || ''
318
- if (!LINK || !CHANNEL) {
319
- logErr(USAGE)
320
- return 2
321
- }
322
- if (!TOKEN) {
323
- logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
324
- logErr(USAGE)
325
- return 2
326
- }
327
- if (!APP_TOKEN) {
328
- logErr('Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to start slack agent.')
329
- logErr(USAGE)
330
- return 2
331
- }
332
- const allowIdx = argv.indexOf('--allow')
333
- const allowSource = allowIdx !== -1 ? '--allow' : (env.SLACK_ALLOW ? 'SLACK_ALLOW' : '--allow')
334
- const rawAllow = allowIdx !== -1 ? (argv[allowIdx + 1] || '') : (env.SLACK_ALLOW || '')
335
- const allowParsed = parseSlackAllow(rawAllow, allowSource)
336
- if (!allowParsed.ok) {
337
- logErr(allowParsed.error)
338
- return 1
339
- }
340
- const pre = await preflightSlackAgent({ token: TOKEN, appToken: APP_TOKEN, channel: CHANNEL, fetchImpl })
341
- if (!pre.ok) {
342
- logErr(pre.error)
343
- return 1
344
- }
345
- const { relay, room } = parseLink(LINK)
346
- const hive = startSync({
347
- relay, room, dir: (env.TEMP || '.') + '/hive-agent-' + room,
348
- name: flag('name', 'Hivecode'), kind: 'human', token: env.HIVE_TOKEN || '', syncFiles: false, log: () => {},
349
- })
350
- const agent = makeAgent({
351
- hive, channel: CHANNEL, token: TOKEN, post: (m) => slackPost(m.method, m, TOKEN, fetchImpl),
352
- teamId: pre.teamId, botUserId: pre.botUserId, allow: allowParsed.ids,
353
- })
354
- let sm
355
- try {
356
- sm = await connectSocketMode({
357
- appToken: APP_TOKEN,
358
- initialUrl: pre.url,
359
- fetchImpl,
360
- WebSocketImpl,
361
- logErr,
362
- onEvent: (ev) => agent.onEvent(ev),
363
- onMultiConnection: (n) => {
364
- slackPost('chat.postMessage', {
365
- channel: CHANNEL,
366
- text: `:warning: Another process connected to Slack Socket Mode with this app token (${n} active connections). Slack distributes incoming events across connections, so messages or "stop" commands may go to the other process. Check for another terminal running hivecode-mcp slack start, github start --slack-allow, or hive-slack-agent.`,
367
- }, TOKEN, fetchImpl).catch(() => {})
368
- },
369
- })
370
- } catch (e) {
371
- logErr(e.message)
372
- try { hive.stop() } catch { }
373
- return 1
374
- }
375
- log(`READY: [hive-slack-agent] ${room} -> ${CHANNEL} (Socket Mode connected) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
376
-
377
- return new Promise((resolve) => {
378
- let done = false
379
- const finish = (code = 0) => {
380
- if (done) return
381
- done = true
382
- process.removeListener('SIGINT', onSignal)
383
- process.removeListener('SIGTERM', onSignal)
384
- try { if (sm && sm.stop) sm.stop() } catch { }
385
- try { hive.stop() } catch { }
386
- resolve(code)
387
- }
388
- const onSignal = () => finish(0)
389
- process.once('SIGINT', onSignal)
390
- process.once('SIGTERM', onSignal)
391
- })
392
- }
393
-
394
- if (process.argv[1] && import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) {
395
- main().then((code) => { if (code !== 0) process.exit(code) })
396
- }
1
+ // hive-slack-agent — the room, rendered onto Slack's agent surface.
2
+ //
3
+ // NAMED hive-slack-agent, NOT hive-agent: hive-agent.js already exists and is the headless join
4
+ // client an AI runs to enter a room at all. This file is a Slack bridge and has nothing to do
5
+ // with that one.
6
+ //
7
+ // node hive-slack-agent.js "<relay|room>" --channel D123 [--name Hivecode] [--allow <U123,U456>]
8
+ // env: SLACK_BOT_TOKEN (xoxb-…), SLACK_APP_TOKEN (xapp-…), optional SLACK_ALLOW, HIVE_SLACK_API
9
+ //
10
+ // WHY THIS AND NOT THE FEED. hive-slack.js posts what HAPPENED into a channel thread: the
11
+ // team's record of the night, read in the morning. This is the other half -- the panel one
12
+ // person opens to see what is happening RIGHT NOW, and to stop it. Both are needed and
13
+ // neither replaces the other.
14
+ //
15
+ // THE STOP BUTTON IS THE PRODUCT. Everything else here is decoration around one guarantee:
16
+ // somebody who is not at their desk can halt every agent in a room from their phone. Slack
17
+ // renders that button natively for any app subscribed to `agent_session_stopped`, so the
18
+ // control panel THE SECOND SHIFT needs is a MAPPING job, not a front-end project. That is the
19
+ // whole reason this file is short.
20
+ //
21
+ // WHAT WE DO NOT PRETEND. The pause is cooperative: control() writes 'paused' into the room and
22
+ // hive_wait refuses to hand that agent new work. An agent mid-step finishes its step. Calling
23
+ // that a kill switch would be a stronger claim than the mechanism supports, and the first
24
+ // person to test it would find out. It stops the NEXT thing, reliably, and that is what makes
25
+ // it safe to walk away.
26
+
27
+ import WebSocket from 'ws'
28
+ import { startSync, parseLink } from './sync.js'
29
+ import { realpathSync as __hiveRealpath } from 'fs'
30
+ import { resolve as __hiveResolve } from 'path'
31
+ import { fileURLToPath as __hiveFileURLToPath } from 'url'
32
+ import {
33
+ makeQueue,
34
+ slackPost,
35
+ stopAll,
36
+ resumeAll,
37
+ resolveName,
38
+ route,
39
+ makeSlackActorCheck,
40
+ formatSlackSender,
41
+ checkAgentScopes,
42
+ preflightSocketMode,
43
+ connectSocketMode,
44
+ parseSlackAllow,
45
+ notFromAPerson,
46
+ } from './hive-slack.js'
47
+
48
+ export { stopAll, resumeAll, resolveName, route }
49
+
50
+ // Slack's plan block knows four states. The room knows four task statuses. They are not the
51
+ // same four, and the join is the only interesting decision in this file.
52
+ //
53
+ // pending -> pending waiting on a human owner to approve it
54
+ // accepted -> in_progress an agent has it and may work
55
+ // done -> completed
56
+ // denied -> error the owner said no; that IS the terminal state, not a failure
57
+ //
58
+ // A PAUSED assignee overrides 'accepted' back to 'pending'. Slack has no paused state, and
59
+ // showing a spinner next to an agent that has been halted is a lie told by the exact panel
60
+ // somebody is trusting to tell them whether they can go to bed. The title carries the reason
61
+ // so it can never be confused with the approval-pending case.
62
+ export const STATE_OF = { pending: 'pending', accepted: 'in_progress', done: 'completed', denied: 'error' }
63
+
64
+ export function taskState(t, paused) {
65
+ const base = STATE_OF[t.status] || 'pending'
66
+ return base === 'in_progress' && paused ? 'pending' : base
67
+ }
68
+
69
+ export function taskTitle(t, paused) {
70
+ const who = t.to || 'unassigned'
71
+ if (paused) return `${who} — ${t.text} (paused)`
72
+ if (t.status === 'pending') return `${who} — ${t.text} (awaiting approval)`
73
+ return `${who} — ${t.text}`
74
+ }
75
+
76
+ /** Read the live room into a plain object. Kept separate from rendering so the renderer can be
77
+ * tested without a relay, and so one read is used for the whole frame -- sampling the CRDT
78
+ * per-task would let the panel show two different moments in the same picture. */
79
+ export function snapshot(hive) {
80
+ const controls = hive.doc.getMap('controls')
81
+ const tasks = [...hive.doc.getMap('tasks').values()]
82
+ const pausedOf = (who) => { const c = controls.get(who); return !!(c && c.state === 'paused') }
83
+ const people = (hive.members ? hive.members() : []) || []
84
+ return {
85
+ tasks: tasks.sort((a, b) => String(a.at).localeCompare(String(b.at))),
86
+ people: people.map((p) => ({ ...p, paused: pausedOf(p.name) })),
87
+ pausedOf,
88
+ open: tasks.filter((t) => t.status === 'accepted').length,
89
+ waiting: tasks.filter((t) => t.status === 'pending').length,
90
+ }
91
+ }
92
+
93
+ /** The room as Slack streaming chunks: a one-line summary, then one task card per task. */
94
+ export function planChunks(snap) {
95
+ const live = snap.people.filter((p) => !p.paused).length
96
+ const head = snap.people.length
97
+ ? `*${live}/${snap.people.length} running* · ${snap.open} in progress · ${snap.waiting} awaiting approval`
98
+ : '_nobody is in the room_'
99
+ const chunks = [{ type: 'markdown_text', markdown_text: head }]
100
+ for (const t of snap.tasks) {
101
+ const paused = snap.pausedOf(t.to)
102
+ chunks.push({
103
+ type: 'task_update',
104
+ task: { task_id: String(t.id), title: taskTitle(t, paused), status: taskState(t, paused) },
105
+ })
106
+ }
107
+ return chunks
108
+ }
109
+
110
+ // stopAll, resumeAll, resolveName, and route live in hive-slack.js so both the channel thread
111
+ // bridge and this Slack agent surface share the exact same deterministic router and case-insensitive
112
+ // participant lookup without duplicating control logic.
113
+
114
+ /** WHO MAY ACT, AND FROM WHERE. This surface used to act for anybody who could type at it: no guest
115
+ * refusal, no --allow, and main() handed it every `message` event the app hears -- which, with
116
+ * message.channels and message.groups in the manifest, is every channel the bot is in. Now it is
117
+ * the same rules as `slack start` (makeSlackActorCheck, one place in hive-slack.js):
118
+ * - stop / stop all / the native stop button: any full workspace member; if Slack cannot say who
119
+ * they are, it stops anyway and says so (stopping is the safe direction);
120
+ * - resume: only user ids in `allow`;
121
+ * - guests and users from other workspaces: refused for everything, status included, so they are
122
+ * never shown the room's tasks.
123
+ * A typed message counts only in the configured channel or in a DM (each user has their own
124
+ * assistant DM, so any `im` channel is a valid place, and the answer goes to THAT DM). */
125
+ export function makeAgent({
126
+ hive, channel, token, post, botName = 'Hivecode', setStatus,
127
+ teamId = '', allow = [], botUserId = '', lookupUser = null, actorCheck = null, api, fetchImpl,
128
+ }) {
129
+ const queue = makeQueue({ send: (m) => post(m) })
130
+ const status = setStatus || ((body) => slackPost('agents.sessions.setStatus', body, token))
131
+ const allowSet = new Set((Array.isArray(allow) ? allow : String(allow || '').split(',')).map((s) => String(s || '').trim()).filter(Boolean))
132
+ const actors = actorCheck || makeSlackActorCheck({ token, teamId, lookupUser, ...(api ? { api } : {}), ...(fetchImpl ? { fetchImpl } : {}) })
133
+
134
+ /** Push the current room onto the surface as one streamed message. */
135
+ async function frame(thread_ts, closing = 'Ask me to stop anything.', ch = channel) {
136
+ const chunks = planChunks(snapshot(hive))
137
+ const started = await queue.push({
138
+ method: 'chat.startStream', channel: ch, thread_ts, task_display_mode: 'plan', chunks: chunks.slice(0, 1),
139
+ })
140
+ const message_ts = started && started.ts
141
+ if (chunks.length > 1) {
142
+ await queue.push({ method: 'chat.appendStream', channel: ch, message_ts, thread_ts, chunks: chunks.slice(1) })
143
+ }
144
+ await queue.push({
145
+ method: 'chat.stopStream', channel: ch, message_ts, thread_ts,
146
+ chunks: [{ type: 'markdown_text', markdown_text: closing }],
147
+ })
148
+ return message_ts
149
+ }
150
+
151
+ // The loading state does NOT clear itself when a message is posted -- Slack leaves the
152
+ // session in `processing` for a full hour if nobody says otherwise. So every path that
153
+ // sets it must clear it, which is why this lives in a finally.
154
+ async function busy(thread_ts, on, ch = channel) {
155
+ try { await status({ status: on ? 'processing' : 'active', channel_id: ch, thread_ts }) } catch { /* the panel still works */ }
156
+ }
157
+
158
+ /** A plain reply in the thread. Used for refusals, which must not stream the room's tasks. */
159
+ async function say(thread_ts, text, ch = channel) {
160
+ try { await queue.push({ method: 'chat.postMessage', channel: ch, thread_ts, text }) } catch { /* nothing more to do */ }
161
+ }
162
+
163
+ /** May this user do this? -> { ok, by, unverified, reply } */
164
+ async function authorize(user, intent) {
165
+ const actor = await actors.resolve(user)
166
+ if (intent === 'stop' || intent === 'stop-all') return actors.mayAct(actor, 'stop', allowSet)
167
+ if (intent === 'resume' || intent === 'resume-all') return actors.mayAct(actor, 'resume', allowSet)
168
+ // status and everything else: any verified full member. mayAct(..., 'reply') would demand
169
+ // --allow, which is for acting on agents, not for reading what is running.
170
+ if (actor && actor.ok) return { ok: true, by: formatSlackSender(actor.handle || actor.userId), unverified: false, reply: '' }
171
+ return actors.mayAct(actor, 'reply', allowSet)
172
+ }
173
+
174
+ /** A human typed something at the agent. */
175
+ async function onPrompt({ text, thread_ts, user, channel: ch = channel }) {
176
+ const r = route(text)
177
+ const check = await authorize(user, r.intent)
178
+ if (!check.ok) {
179
+ if (check.reply) await say(thread_ts, check.reply, ch)
180
+ return { r, refused: check.refused || 'refused' }
181
+ }
182
+ const by = check.by || formatSlackSender(user)
183
+ const note = check.unverified ? ` (${check.reply})` : ''
184
+ await busy(thread_ts, true, ch)
185
+ try {
186
+ if (r.intent === 'stop-all') {
187
+ const who = stopAll(hive, by)
188
+ return { r, ts: await frame(thread_ts, who.length ? `:octagonal_sign: paused *${who.join('*, *')}*. Nothing new will be picked up.${note}` : 'No agents to pause.', ch) }
189
+ }
190
+ if (r.intent === 'resume-all') {
191
+ const who = resumeAll(hive, by)
192
+ return { r, ts: await frame(thread_ts, who.length ? `:arrow_forward: resumed *${who.join('*, *')}*.` : 'No agents to resume.', ch) }
193
+ }
194
+ if (r.intent === 'stop' || r.intent === 'resume') {
195
+ const real = resolveName(hive, r.who)
196
+ if (!real) {
197
+ const here = (hive.members ? hive.members() : []).map((p) => p.name).join(', ') || 'nobody'
198
+ return { r, ok: false, ts: await frame(thread_ts, `I do not see *${r.who}* in this room. Here right now: ${here}.`, ch) }
199
+ }
200
+ hive.control(real, r.intent === 'stop' ? 'paused' : 'running', by)
201
+ return { r, ok: true, who: real, ts: await frame(thread_ts, `${r.intent === 'stop' ? ':octagonal_sign: paused' : ':arrow_forward: resumed'} *${real}*.${r.intent === 'stop' ? note : ''}`, ch) }
202
+ }
203
+ if (r.intent === 'status') return { r, ts: await frame(thread_ts, undefined, ch) }
204
+ return { r, ts: await frame(thread_ts, `I can stop, resume and report. I did not act on "${r.text}".`, ch) }
205
+ } finally {
206
+ await busy(thread_ts, false, ch)
207
+ }
208
+ }
209
+
210
+ /** Slack's native stop button. This is the one path that must never be clever. */
211
+ async function onSessionStopped({ thread_ts, user, channel: ch = channel }) {
212
+ const check = await authorize(user, 'stop-all')
213
+ if (!check.ok) {
214
+ if (check.reply) await say(thread_ts, check.reply, ch)
215
+ await busy(thread_ts, false, ch)
216
+ return []
217
+ }
218
+ const who = stopAll(hive, check.by || formatSlackSender(user))
219
+ await busy(thread_ts, false, ch)
220
+ return who
221
+ }
222
+
223
+ /** The one entry point for Slack events. Only a message in the configured channel or in a DM, or
224
+ * the native stop button, does anything; everything else is ignored before any actor lookup. */
225
+ async function onEvent(ev) {
226
+ if (notFromAPerson(ev) || (botUserId && ev.user === botUserId)) return { ignored: 'bot_or_subtype' }
227
+ const targetChannel = ev.channel || channel
228
+ const isDm = ev.channel_type === 'im' || (typeof targetChannel === 'string' && targetChannel.startsWith('D'))
229
+ const here = targetChannel === channel || isDm
230
+ if (!here) return { ignored: 'wrong_channel' }
231
+ if (ev.type === 'agent_session_stopped') {
232
+ const who = await onSessionStopped({ thread_ts: ev.thread_ts || ev.ts, user: ev.user, channel: targetChannel })
233
+ return { action: 'session-stopped', stopped: who }
234
+ }
235
+ if (ev.type !== 'message' || !ev.text) return { ignored: 'unsupported_event' }
236
+ return onPrompt({ text: ev.text, thread_ts: ev.thread_ts || ev.ts, user: ev.user, channel: targetChannel })
237
+ }
238
+
239
+ return { onPrompt, onSessionStopped, onEvent, frame, queue }
240
+ }
241
+
242
+ // Suggested prompts are declared once, in slack-app-manifest-agent.json
243
+ // (features.assistant_view.suggested_prompts); there is no second copy in code to drift from it.
244
+
245
+ /** Verify SLACK_BOT_TOKEN scopes (chat:write, assistant:write, im:history/channels:history/groups:history, users:read)
246
+ * and SLACK_APP_TOKEN Socket Mode connection before starting hive-slack-agent (Q630, Q634). */
247
+ export async function preflightSlackAgent({
248
+ token = '',
249
+ appToken = '',
250
+ channel = '',
251
+ api,
252
+ fetchImpl = fetch,
253
+ } = {}) {
254
+ if (!token || !String(token).trim()) {
255
+ return {
256
+ ok: false,
257
+ code: 'Q630',
258
+ error: 'Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.',
259
+ }
260
+ }
261
+ if (!appToken || !String(appToken).trim()) {
262
+ return {
263
+ ok: false,
264
+ code: 'Q634',
265
+ error: 'Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to start slack agent.',
266
+ }
267
+ }
268
+ let auth
269
+ try {
270
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
271
+ } catch (e) {
272
+ if (!e || !e.slackError) {
273
+ const why = e.message || 'network error'
274
+ return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
275
+ }
276
+ return {
277
+ ok: false,
278
+ code: 'Q630',
279
+ error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${e.code || e.message || 'auth_failed'}). Check the token or reinstall the Slack app.`,
280
+ }
281
+ }
282
+ const scopes = Array.isArray(auth && auth._scopes) ? auth._scopes : null
283
+ const sc = checkAgentScopes({ scopes, channel })
284
+ if (!sc.ok) return sc
285
+ // Without the workspace id, users from OTHER workspaces (Slack Connect) cannot be told apart, so
286
+ // nobody could be refused as an outsider. Refused here, as hive-github.js and hive-mcp-http.js do.
287
+ if (!auth || !auth.team_id) {
288
+ return { ok: false, code: 'Q634', error: 'Q634 Slack did not say which workspace the bot is in (auth.test returned no team_id), so users from other workspaces could not be refused. Reinstall the Slack app.' }
289
+ }
290
+ const sockPre = await preflightSocketMode({ appToken, api, fetchImpl })
291
+ if (!sockPre.ok) return sockPre
292
+ return {
293
+ ok: true,
294
+ url: sockPre.url,
295
+ botUser: (auth && auth.user) || 'Hivecode',
296
+ botUserId: (auth && auth.user_id) || '',
297
+ teamId: (auth && auth.team_id) || '',
298
+ scopes,
299
+ }
300
+ }
301
+
302
+ // ---- main -------------------------------------------------------------------------------
303
+ export const USAGE = `usage:
304
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "<relay|room>" --channel <id> [--name <name>] [--allow <U123,U456>]`
305
+
306
+ export async function main(argv = process.argv.slice(2), {
307
+ log = console.log,
308
+ logErr = console.error,
309
+ env = process.env,
310
+ fetchImpl = fetch,
311
+ WebSocketImpl = WebSocket,
312
+ } = {}) {
313
+ if (argv.includes('--help') || argv.includes('-h') || argv.includes('help')) {
314
+ log(USAGE)
315
+ return 0
316
+ }
317
+ const flag = (k, d = '') => { const i = argv.indexOf('--' + k); return i === -1 ? d : (argv[i + 1] || '') }
318
+ const LINK = argv.find((a) => !a.startsWith('--')) || ''
319
+ const CHANNEL = flag('channel')
320
+ const TOKEN = env.SLACK_BOT_TOKEN || ''
321
+ const APP_TOKEN = env.SLACK_APP_TOKEN || ''
322
+ if (!LINK || !CHANNEL) {
323
+ logErr(USAGE)
324
+ return 2
325
+ }
326
+ if (!TOKEN) {
327
+ logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
328
+ logErr(USAGE)
329
+ return 2
330
+ }
331
+ if (!APP_TOKEN) {
332
+ logErr('Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to start slack agent.')
333
+ logErr(USAGE)
334
+ return 2
335
+ }
336
+ const allowIdx = argv.indexOf('--allow')
337
+ const allowSource = allowIdx !== -1 ? '--allow' : (env.SLACK_ALLOW ? 'SLACK_ALLOW' : '--allow')
338
+ const rawAllow = allowIdx !== -1 ? (argv[allowIdx + 1] || '') : (env.SLACK_ALLOW || '')
339
+ const allowParsed = parseSlackAllow(rawAllow, allowSource)
340
+ if (!allowParsed.ok) {
341
+ logErr(allowParsed.error)
342
+ return 1
343
+ }
344
+ const pre = await preflightSlackAgent({ token: TOKEN, appToken: APP_TOKEN, channel: CHANNEL, fetchImpl })
345
+ if (!pre.ok) {
346
+ logErr(pre.error)
347
+ return 1
348
+ }
349
+ const { relay, room } = parseLink(LINK)
350
+ const hive = startSync({
351
+ relay, room, dir: (env.TEMP || '.') + '/hive-agent-' + room,
352
+ name: flag('name', 'Hivecode'), kind: 'human', token: env.HIVE_TOKEN || '', syncFiles: false, log: () => {},
353
+ })
354
+ const agent = makeAgent({
355
+ hive, channel: CHANNEL, token: TOKEN, post: (m) => slackPost(m.method, m, TOKEN, fetchImpl),
356
+ teamId: pre.teamId, botUserId: pre.botUserId, allow: allowParsed.ids,
357
+ })
358
+ let sm
359
+ try {
360
+ sm = await connectSocketMode({
361
+ appToken: APP_TOKEN,
362
+ initialUrl: pre.url,
363
+ fetchImpl,
364
+ WebSocketImpl,
365
+ logErr,
366
+ onEvent: (ev) => agent.onEvent(ev),
367
+ onMultiConnection: (n) => {
368
+ slackPost('chat.postMessage', {
369
+ channel: CHANNEL,
370
+ text: `:warning: Another process connected to Slack Socket Mode with this app token (${n} active connections). Slack distributes incoming events across connections, so messages or "stop" commands may go to the other process. Check for another terminal running hivecode-mcp slack start, github start --slack-allow, or hive-slack-agent.`,
371
+ }, TOKEN, fetchImpl).catch(() => {})
372
+ },
373
+ })
374
+ } catch (e) {
375
+ logErr(e.message)
376
+ try { hive.stop() } catch { }
377
+ return 1
378
+ }
379
+ log(`READY: [hive-slack-agent] ${room} -> ${CHANNEL} (Socket Mode connected) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
380
+
381
+ return new Promise((resolve) => {
382
+ let done = false
383
+ const finish = (code = 0) => {
384
+ if (done) return
385
+ done = true
386
+ process.removeListener('SIGINT', onSignal)
387
+ process.removeListener('SIGTERM', onSignal)
388
+ try { if (sm && sm.stop) sm.stop() } catch { }
389
+ try { hive.stop() } catch { }
390
+ resolve(code)
391
+ }
392
+ const onSignal = () => finish(0)
393
+ process.once('SIGINT', onSignal)
394
+ process.once('SIGTERM', onSignal)
395
+ })
396
+ }
397
+
398
+ // Run directly (node <file>, or through an npx / node_modules/.bin symlink)? Compared by real path:
399
+ // the old `file:///${argv[1]}` test built "file:////..." on macOS and Linux, never matched, and the
400
+ // process exited 0 having done nothing.
401
+ function __hiveIsEntry() {
402
+ if (!process.argv[1]) return false
403
+ const me = __hiveFileURLToPath(import.meta.url)
404
+ try { return __hiveRealpath(process.argv[1]) === __hiveRealpath(me) } catch { return __hiveResolve(process.argv[1]) === __hiveResolve(me) }
405
+ }
406
+ if (__hiveIsEntry()) {
407
+ main().then((code) => { if (code !== 0) process.exit(code) })
408
+ }