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.
- package/HIVE_RULES.md +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +394 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +105 -0
- package/hive-authors.js +44 -0
- package/hive-await.js +35 -10
- package/hive-branch.js +101 -150
- package/hive-github.js +1944 -0
- package/hive-land.js +691 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +173 -29
- package/hive-notify.js +233 -0
- package/hive-pr.js +205 -29
- package/hive-relay-health.js +230 -230
- package/hive-repos.js +430 -0
- package/hive-room-key.js +209 -0
- package/hive-room-mode.js +1668 -0
- package/hive-room-watcher.js +561 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +8 -5
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +190 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4992 -4691
package/hive-session.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|