hivecode-mcp 0.12.4 → 0.13.0
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 +334 -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 +87 -0
- package/hive-await.js +25 -5
- package/hive-branch.js +55 -25
- package/hive-github.js +1904 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +109 -24
- package/hive-notify.js +233 -0
- package/hive-pr.js +17 -4
- package/hive-relay-health.js +230 -230
- package/hive-room-key.js +209 -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 +7 -4
- 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 +189 -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 +4790 -4691
|
@@ -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
|
+
}
|