hivecode-mcp 0.12.4 → 0.13.1

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.
@@ -0,0 +1,114 @@
1
+ // hive-session.js -- which agent this is, on disk, for the folder its app started it in.
2
+ //
3
+ // The MCP server records its session in <launch folder>/.hive-mcp-session.json, so a restart rejoins
4
+ // as the same agent; the Stop hook, which runs in the same launch folder, reads it to learn which
5
+ // agent it is guarding.
6
+ //
7
+ // WHY THIS IS A MODULE. Until 9 Oct 2026 that file held ONE agent. Two agents started from one folder
8
+ // -- two Claude Code sessions in one repo, the obvious way to run a branch room -- overwrote each
9
+ // other, and the last writer was the only agent on record. The other one's Stop hook then steered it
10
+ // as the wrong agent (that agent's mail, a listener under that agent's name), and a restart brought
11
+ // it back as the other one. Found by walking the founder flow end to end (branch -> two agents ->
12
+ // pr), where the file also showed up in the developer's `git status`.
13
+ //
14
+ // Now the file keeps every agent started in that folder, each with the pid of the server that wrote
15
+ // it. Its top level is still the latest writer, which is all a Stop hook from before this reads, so an
16
+ // older hook keeps working exactly as it did. Server and hook read it through this one module, so
17
+ // the two cannot drift apart (the merge logic once lived twice, and the copies did).
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import { spawnSync } from 'child_process'
21
+
22
+ export const SESSION_FILE_NAME = '.hive-mcp-session.json'
23
+ export const HOOK_STATE_NAME = '.hive-stop-hook.json'
24
+ const STALE_MS = 14 * 24 * 3600 * 1000 // a record whose server is gone and that nobody wrote for two weeks
25
+
26
+ export function pidAlive(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0) return false
28
+ try { process.kill(pid, 0); return true } catch (e) { return !!e && e.code === 'EPERM' }
29
+ }
30
+
31
+ /** Every agent on record in a session file, by name. A file from before 9 Oct holds one, at its top level. */
32
+ export function readAgents(file) {
33
+ let raw
34
+ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return {} }
35
+ if (!raw || typeof raw !== 'object') return {}
36
+ if (raw.agents && typeof raw.agents === 'object') return { ...raw.agents }
37
+ if (raw.name) { const one = { ...raw }; delete one.agents; return { [raw.name]: one } }
38
+ return {}
39
+ }
40
+
41
+ /** Write the records back; the top level is `latest` (or the newest record), for a Stop hook from before 9 Oct. */
42
+ export function writeAgents(file, agents, latest = null) {
43
+ const now = Date.now()
44
+ for (const [n, s] of Object.entries(agents)) if (!pidAlive(s.pid) && s.at && now - s.at > STALE_MS) delete agents[n]
45
+ const names = Object.keys(agents)
46
+ if (!names.length) { try { fs.rmSync(file) } catch { } return }
47
+ const top = latest && agents[latest.name] === latest ? latest
48
+ : agents[names.sort((a, b) => (agents[b].at || 0) - (agents[a].at || 0))[0]]
49
+ fs.writeFileSync(file, JSON.stringify({ ...top, agents }))
50
+ }
51
+
52
+ /** The record this server process owns: the one it wrote; else the one for its conversation; else, after
53
+ * a restart, the only one whose server is gone. Two gone is a guess, and a guess here is exactly how one
54
+ * agent wakes up as another, so it answers null and the agent rejoins by name instead. */
55
+ export function ownRecord(agents, { pid = process.pid, conv = '' } = {}) {
56
+ const all = Object.values(agents)
57
+ const mine = all.find((s) => s.pid === pid) || (conv ? all.find((s) => s.conv === conv) : null)
58
+ if (mine) return mine
59
+ const gone = all.filter((s) => !pidAlive(s.pid))
60
+ return gone.length === 1 ? gone[0] : null
61
+ }
62
+
63
+ /** Which agent a Stop hook is guarding. One on record is that one. With several, the hook's own
64
+ * conversation decides: its id, else its transcript (the hive_join that answered "folder: <dir>",
65
+ * then that folder anywhere in it), else the only one whose server still runs. If none of that
66
+ * tells, null: a hook that speaks as the wrong agent does more harm than one that stays quiet. */
67
+ export function agentForHook(agents, hook = {}, { readTail = tailOf } = {}) {
68
+ const all = Object.values(agents).filter((s) => s && s.dir && s.name)
69
+ if (all.length <= 1) return all[0] || null
70
+ const conv = String(hook.session_id || hook.conversationId || '')
71
+ if (conv) { const byConv = all.find((s) => s.conv && s.conv === conv); if (byConv) return byConv }
72
+ const t = hook.transcript_path ? readTail(hook.transcript_path) : ''
73
+ if (t) {
74
+ // The folder as a transcript line spells it: escaped once as JSON, twice if the text was nested.
75
+ const spell = (dir) => { const once = JSON.stringify(String(dir)).slice(1, -1); return [once, JSON.stringify(once).slice(1, -1)] }
76
+ const where = (s) => {
77
+ const forms = spell(s.dir)
78
+ const joined = Math.max(...forms.map((f) => t.lastIndexOf('folder: ' + f)))
79
+ return joined >= 0 ? [1, joined] : [0, Math.max(...forms.map((f) => t.lastIndexOf(f)))]
80
+ }
81
+ const ranked = all.map((s) => ({ s, w: where(s) })).filter((x) => x.w[1] >= 0)
82
+ .sort((a, b) => b.w[0] - a.w[0] || b.w[1] - a.w[1])
83
+ if (ranked.length) return ranked[0].s
84
+ }
85
+ const live = all.filter((s) => pidAlive(s.pid))
86
+ return live.length === 1 ? live[0] : null
87
+ }
88
+
89
+ function tailOf(file, max = 8 * 1024 * 1024) {
90
+ try {
91
+ const size = fs.statSync(file).size
92
+ const n = Math.min(size, max)
93
+ const fd = fs.openSync(file, 'r')
94
+ try { const b = Buffer.alloc(n); fs.readSync(fd, b, 0, n, size - n); return b.toString('utf8') } finally { fs.closeSync(fd) }
95
+ } catch { return '' }
96
+ }
97
+
98
+ /** Keep our files out of `git status` in the launch folder, which is often the developer's own repo:
99
+ * listed in that repo's local exclude file (.git/info/exclude), never in a tracked .gitignore. */
100
+ export function excludeFromGit(dir, names = [SESSION_FILE_NAME, HOOK_STATE_NAME]) {
101
+ try {
102
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: dir, encoding: 'utf8', windowsHide: true, timeout: 5000 })
103
+ if (r.status !== 0 || !String(r.stdout || '').trim()) return false
104
+ const file = path.resolve(dir, String(r.stdout).trim())
105
+ let have = ''
106
+ try { have = fs.readFileSync(file, 'utf8') } catch { }
107
+ const listed = new Set(have.split(/\r?\n/).map((l) => l.trim()))
108
+ const add = names.filter((n) => !listed.has(n) && !listed.has('/' + n))
109
+ if (!add.length) return true
110
+ fs.mkdirSync(path.dirname(file), { recursive: true })
111
+ fs.appendFileSync(file, `${have && !have.endsWith('\n') ? '\n' : ''}# Hivecode's local session files\n${add.join('\n')}\n`)
112
+ return true
113
+ } catch { return false }
114
+ }
package/hive-setup.js CHANGED
@@ -259,13 +259,14 @@ export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
259
259
  * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
260
260
  * npm prunes it and the config would break months later with nothing to connect it to setup.
261
261
  */
262
- export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
262
+ export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url), { log } = {}) {
263
263
  const pkgDir = path.dirname(here)
264
264
  const nm = path.dirname(pkgDir)
265
265
  if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
266
266
  const dest = path.join(home, '.hivecode', 'runtime', VERSION)
267
267
  const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
268
268
  if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
269
+ if (log) log(` installing runtime under ${dest} (first run can take ~25s on Windows)...`)
269
270
  // ONLY the dependency closure is copied, never the whole node_modules around us: that would
270
271
  // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
271
272
  const closure = new Set()
@@ -320,7 +321,14 @@ export function projectRepo(cwd = process.cwd(), home = os.homedir()) {
320
321
  }
321
322
  const top = git('rev-parse', '--show-toplevel')
322
323
  if (!top || !git('rev-parse', '--verify', '-q', 'HEAD^{commit}')) return null
323
- const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p))
324
+ // THROUGH LINKS (6 Oct 2026, the first macOS run). git names the top folder with links resolved,
325
+ // and the home folder comes as written. macOS temp folders sit behind one (/var is a link to
326
+ // /private/var), so a home kept in git was offered as a project there (F7). Both sides are resolved.
327
+ const norm = (p) => {
328
+ let r = path.resolve(p)
329
+ try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
330
+ return process.platform === 'win32' ? r.toLowerCase() : r
331
+ }
324
332
  return norm(top) === norm(home) ? null : path.resolve(top)
325
333
  }
326
334
 
@@ -372,7 +380,7 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
372
380
  if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
373
381
 
374
382
  let runtime
375
- try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
383
+ try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home, undefined, { log }) }
376
384
  catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
377
385
  log(` runtime ${runtime.js}`)
378
386
  log(` node ${process.execPath}\n`)
@@ -400,7 +408,8 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
400
408
  // A project's own .mcp.json outranks everything written above, inside that project. It is a
401
409
  // shared, committed file, so it is reported rather than rewritten with this machine's paths.
402
410
  const projectMcp = path.join(process.cwd(), '.mcp.json')
403
- if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
411
+ // Removing the entry only here did not last on 9 Oct 2026: the file was committed, and a checkout put it back.
412
+ if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry. If the file is committed, remove it in the repository: a copy changed only here comes back with the next checkout or pull.\n`)
404
413
 
405
414
  let failed = 0
406
415
  const ready = []
@@ -539,6 +548,7 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
539
548
  }
540
549
  }
541
550
  log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
551
+ log(` Two agents on one machine: open two Claude Code terminals, or pair two apps (GUI editors share one MCP process across tabs).`)
542
552
  // With a link, the room itself: its relay's Control Room with the link in the hash, so a person
543
553
  // never pastes wss:// into an address bar. Without one, the panel they paste a link into.
544
554
  const panel = (link && controlUrl(link)) || `${(process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).replace(/^ws/, 'http').replace(/\/+$/, '')}/control`
@@ -0,0 +1,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 {
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
+ }