claude-slack-channel-bots 0.1.2 → 0.1.4
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/README.md +26 -3
- package/hooks/ask-relay.sh +5 -0
- package/hooks/permission-relay.sh +5 -0
- package/package.json +1 -1
- package/skills/setup-slack-channel-bots/SKILL.md +3 -1
- package/src/cli.ts +100 -6
- package/src/session-manager.ts +1 -1
package/README.md
CHANGED
|
@@ -173,7 +173,7 @@ If you changed `port` or `bind` in `routing.json`, update the `url` here to matc
|
|
|
173
173
|
|
|
174
174
|
## CLI Reference
|
|
175
175
|
|
|
176
|
-
The `claude-slack-channel-bots` binary exposes
|
|
176
|
+
The `claude-slack-channel-bots` binary exposes three subcommands.
|
|
177
177
|
|
|
178
178
|
### `claude-slack-channel-bots start`
|
|
179
179
|
|
|
@@ -202,6 +202,21 @@ Behavior by case:
|
|
|
202
202
|
- **Stale PID file** (process no longer running): removes the PID file, prints `server is not running (removed stale PID file)`, exits 0.
|
|
203
203
|
- **Live process:** sends `SIGTERM`, polls for exit every 100ms for up to 5 seconds. Prints `[slack] Server stopped.` on clean exit. Prints a warning if the server does not stop within 5 seconds (does not force-kill).
|
|
204
204
|
|
|
205
|
+
### `claude-slack-channel-bots clean_restart`
|
|
206
|
+
|
|
207
|
+
Gracefully exits all managed Claude Code sessions, then stops and starts the server.
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
claude-slack-channel-bots clean_restart
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
For each session in `sessions.json`, sends `/exit` to the tmux session and polls until Claude exits. All sessions are processed in parallel. If a session does not exit within 60 seconds, its tmux session is force-killed. Individual session errors are logged and do not abort the restart. After the server restarts, sessions are relaunched with `--resume` using the stored session IDs in `sessions.json`, preserving conversation context.
|
|
214
|
+
|
|
215
|
+
Behavior by case:
|
|
216
|
+
|
|
217
|
+
- **No sessions.json or no sessions:** skips the shutdown phase and proceeds directly to stop/start.
|
|
218
|
+
- **Server already stopped:** `stop` reports `server is not running`; `start` then brings up a fresh server.
|
|
219
|
+
|
|
205
220
|
### PID file
|
|
206
221
|
|
|
207
222
|
The PID file is stored at `STATE_DIR/server.pid` (default: `~/.claude/channels/slack/server.pid`). It is written on startup and removed on clean shutdown. A conflict check at startup prevents running two servers against the same state directory.
|
|
@@ -250,6 +265,8 @@ When Claude Code requires tool approval, the permission relay surfaces an intera
|
|
|
250
265
|
|
|
251
266
|
The `ask-relay.sh` hook intercepts `AskUserQuestion` tool calls via `PreToolUse`, posts the question and its options to Slack as interactive buttons, and waits for the user's selection. The answer is returned to Claude Code via `updatedInput` without blocking the TUI.
|
|
252
267
|
|
|
268
|
+
Both hooks are **scope-guarded**: they check for the `SLACK_CHANNEL_BOT_SESSION` environment variable and exit immediately (no-op) if it is not set. The server sets this variable on every Claude session it launches. This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot.
|
|
269
|
+
|
|
253
270
|
Both hooks use a **two-phase long-poll protocol**:
|
|
254
271
|
|
|
255
272
|
1. **Phase 1 — Create request:** The hook POSTs to `/permission` (or `/ask`) with the tool name, input, and CWD. The server posts an interactive Slack message and returns a `requestId`.
|
|
@@ -319,11 +336,17 @@ The `update-config` skill can automate hook installation. It copies or symlinks
|
|
|
319
336
|
**routing.json CWD mismatch**
|
|
320
337
|
If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `routing.json`. Confirm the session's working directory matches the entry exactly (after tilde expansion). Duplicate CWDs across multiple routes are rejected at startup.
|
|
321
338
|
|
|
339
|
+
**Bot not receiving messages in a new channel**
|
|
340
|
+
After inviting the bot to a channel, Slack may not deliver messages until the bot is @mentioned for the first time. This is a Slack Socket Mode behavior — the first @mention activates event delivery for that channel. After that, all messages flow normally regardless of `requireMention` settings.
|
|
341
|
+
|
|
322
342
|
**Channel not in access.json**
|
|
323
|
-
Messages to channels not listed in `access.json → channels` are silently dropped. Use the `slack-
|
|
343
|
+
Messages to channels not listed in `access.json → channels` and not present in `routing.json → routes` are silently dropped. Use the `claude-slack-channels-config` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
|
|
324
344
|
|
|
325
345
|
**Permission relay not working**
|
|
326
|
-
Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `routing.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port.
|
|
346
|
+
Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `routing.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks only activate when `SLACK_CHANNEL_BOT_SESSION=1` is present in the environment. Sessions launched manually will not trigger the relay.
|
|
327
347
|
|
|
328
348
|
**Session not restarting after crash**
|
|
329
349
|
After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `routing.json`.
|
|
350
|
+
|
|
351
|
+
**Session stuck during clean_restart**
|
|
352
|
+
If a session does not exit within 60 seconds, `clean_restart` force-kills its tmux session and proceeds. To manually recover, run `tmux kill-session -t <session-name>` for any remaining sessions, then `claude-slack-channel-bots stop && claude-slack-channel-bots start`.
|
package/hooks/ask-relay.sh
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
# AskUserQuestion relay — intercepts via PreToolUse, posts to Slack, returns answer
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
|
|
5
|
+
# Guard: only relay for bot-managed sessions
|
|
6
|
+
if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
|
|
5
10
|
# Only handle AskUserQuestion
|
|
6
11
|
INPUT=$(cat)
|
|
7
12
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
# permission-relay.sh - Claude Code PermissionRequest hook
|
|
3
3
|
# Implements two-phase long-poll to relay permission decisions via Slack channel server
|
|
4
4
|
|
|
5
|
+
# Guard: only relay for bot-managed sessions
|
|
6
|
+
if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
|
|
5
10
|
# Check dependencies
|
|
6
11
|
if ! command -v jq &>/dev/null; then
|
|
7
12
|
exit 0
|
package/package.json
CHANGED
|
@@ -174,7 +174,9 @@ After collecting at least one route, write the updated `routing.json`.
|
|
|
174
174
|
**Important:** Remind the user to invite the bot to each channel they configured.
|
|
175
175
|
In Slack, type `/invite @Claude Slack Channel Bots` in each channel (or whatever
|
|
176
176
|
the bot's display name is). The bot cannot receive messages from channels it has
|
|
177
|
-
not been invited to.
|
|
177
|
+
not been invited to. After inviting, send an @mention to the bot in each channel
|
|
178
|
+
— Slack may not deliver messages until the first @mention activates event
|
|
179
|
+
delivery for that channel.
|
|
178
180
|
|
|
179
181
|
**Optional routing fields** — after required routes are set, offer these with
|
|
180
182
|
their defaults. Prompt only if the user wants to customise:
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { join, resolve } from 'path'
|
|
|
14
14
|
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
|
15
15
|
import { spawnSync } from 'child_process'
|
|
16
16
|
import { isProcessRunning } from './pid.ts'
|
|
17
|
+
import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning } from './tmux.ts'
|
|
18
|
+
import { readSessions, type SessionsMap } from './sessions.ts'
|
|
17
19
|
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// Injectable dependency interface
|
|
@@ -40,6 +42,16 @@ export interface CliDeps {
|
|
|
40
42
|
startServer: () => Promise<void>
|
|
41
43
|
/** Exit the process. */
|
|
42
44
|
exit: (code: number) => never
|
|
45
|
+
/** Returns true if a tmux session with the given name exists. */
|
|
46
|
+
hasSession: (name: string) => Promise<boolean>
|
|
47
|
+
/** Sends keystrokes to the given tmux session. */
|
|
48
|
+
sendKeys: (session: string, keys: string) => Promise<void>
|
|
49
|
+
/** Returns true if a 'claude' process is running in the given tmux session. */
|
|
50
|
+
isClaudeRunning: (session: string) => Promise<boolean>
|
|
51
|
+
/** Kills the named tmux session. */
|
|
52
|
+
killSession: (session: string) => Promise<void>
|
|
53
|
+
/** Read the sessions registry. */
|
|
54
|
+
readSessions: () => SessionsMap
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +70,7 @@ function defaultStateDir(): string {
|
|
|
58
70
|
export interface CliHandlers {
|
|
59
71
|
start: () => Promise<void>
|
|
60
72
|
stop: () => Promise<void>
|
|
73
|
+
clean_restart: () => Promise<void>
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/**
|
|
@@ -154,7 +167,77 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
154
167
|
deps.exit(0)
|
|
155
168
|
}
|
|
156
169
|
|
|
157
|
-
|
|
170
|
+
async function clean_restart(): Promise<void> {
|
|
171
|
+
const sessions = deps.readSessions()
|
|
172
|
+
const entries = Object.entries(sessions)
|
|
173
|
+
|
|
174
|
+
if (entries.length > 0) {
|
|
175
|
+
console.error(`[slack] clean_restart: sending /exit to ${entries.length} session(s)`)
|
|
176
|
+
|
|
177
|
+
// Fan out: send /exit + Enter to all sessions in parallel (best-effort)
|
|
178
|
+
await Promise.all(entries.map(async ([channelId, record]) => {
|
|
179
|
+
try {
|
|
180
|
+
const exists = await deps.hasSession(record.tmuxSession)
|
|
181
|
+
if (!exists) {
|
|
182
|
+
console.error(`[slack] clean_restart: session not found for channel=${channelId}, skipping`)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
await deps.sendKeys(record.tmuxSession, '/exit')
|
|
186
|
+
await deps.sendKeys(record.tmuxSession, 'Enter')
|
|
187
|
+
console.error(`[slack] clean_restart: sent /exit to channel=${channelId} session=${record.tmuxSession}`)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`[slack] clean_restart: error sending /exit to channel=${channelId}:`, err)
|
|
190
|
+
}
|
|
191
|
+
}))
|
|
192
|
+
|
|
193
|
+
// Poll each session until Claude exits or 60s timeout (best-effort)
|
|
194
|
+
await Promise.all(entries.map(async ([channelId, record]) => {
|
|
195
|
+
try {
|
|
196
|
+
const exists = await deps.hasSession(record.tmuxSession)
|
|
197
|
+
if (!exists) return
|
|
198
|
+
|
|
199
|
+
const timeout = 60_000
|
|
200
|
+
const start = Date.now()
|
|
201
|
+
let delay = 500
|
|
202
|
+
const maxDelay = 5_000
|
|
203
|
+
|
|
204
|
+
while (Date.now() - start < timeout) {
|
|
205
|
+
const running = await deps.isClaudeRunning(record.tmuxSession)
|
|
206
|
+
if (!running) {
|
|
207
|
+
console.error(`[slack] clean_restart: channel=${channelId} exited cleanly`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
await new Promise<void>((r) => setTimeout(r, delay))
|
|
211
|
+
delay = Math.min(delay * 2, maxDelay)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Timed out — force kill
|
|
215
|
+
console.error(`[slack] clean_restart: timeout waiting for channel=${channelId}, force-killing`)
|
|
216
|
+
try {
|
|
217
|
+
await deps.killSession(record.tmuxSession)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`[slack] clean_restart: killSession failed for channel=${channelId}:`, err)
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`[slack] clean_restart: error polling channel=${channelId}:`, err)
|
|
223
|
+
}
|
|
224
|
+
}))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Stop the server
|
|
228
|
+
console.error('[slack] clean_restart: stopping server')
|
|
229
|
+
deps.spawnSync(process.execPath, [process.argv[1], 'stop'])
|
|
230
|
+
|
|
231
|
+
// Start the server
|
|
232
|
+
console.error('[slack] clean_restart: starting server')
|
|
233
|
+
const startResult = deps.spawnSync(process.execPath, [process.argv[1], 'start'])
|
|
234
|
+
if (startResult.status !== 0) {
|
|
235
|
+
console.error(`[slack] clean_restart: start failed with exit code ${startResult.status}`)
|
|
236
|
+
deps.exit(startResult.status ?? 1)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { start, stop, clean_restart }
|
|
158
241
|
}
|
|
159
242
|
|
|
160
243
|
// ---------------------------------------------------------------------------
|
|
@@ -164,11 +247,12 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
164
247
|
if (import.meta.main) {
|
|
165
248
|
const subcommand = process.argv[2]
|
|
166
249
|
|
|
167
|
-
if (subcommand !== 'start' && subcommand !== 'stop') {
|
|
168
|
-
console.error('Usage: cli.ts <start|stop>')
|
|
250
|
+
if (subcommand !== 'start' && subcommand !== 'stop' && subcommand !== 'clean_restart') {
|
|
251
|
+
console.error('Usage: cli.ts <start|stop|clean_restart>')
|
|
169
252
|
console.error('')
|
|
170
|
-
console.error(' start
|
|
171
|
-
console.error(' stop
|
|
253
|
+
console.error(' start Validate prerequisites and start the server in the background')
|
|
254
|
+
console.error(' stop Send SIGTERM to a running server')
|
|
255
|
+
console.error(' clean_restart Exit all managed sessions, then stop and start the server')
|
|
172
256
|
process.exit(1)
|
|
173
257
|
}
|
|
174
258
|
|
|
@@ -183,6 +267,11 @@ if (import.meta.main) {
|
|
|
183
267
|
resolveStateDir: defaultStateDir,
|
|
184
268
|
startServer: async () => { const { main } = await import('./server.ts'); return main() },
|
|
185
269
|
exit: (code) => process.exit(code),
|
|
270
|
+
hasSession: (name) => defaultTmuxClient.hasSession(name),
|
|
271
|
+
sendKeys: (session, keys) => defaultTmuxClient.sendKeys(session, keys),
|
|
272
|
+
isClaudeRunning: (session) => tmuxIsClaudeRunning(session, defaultTmuxClient),
|
|
273
|
+
killSession: (session) => defaultTmuxClient.killSession(session),
|
|
274
|
+
readSessions: () => readSessions(),
|
|
186
275
|
}
|
|
187
276
|
|
|
188
277
|
const cli = createCli(realDeps)
|
|
@@ -192,10 +281,15 @@ if (import.meta.main) {
|
|
|
192
281
|
console.error('[slack] Fatal:', err)
|
|
193
282
|
process.exit(1)
|
|
194
283
|
})
|
|
195
|
-
} else {
|
|
284
|
+
} else if (subcommand === 'stop') {
|
|
196
285
|
cli.stop().catch((err) => {
|
|
197
286
|
console.error('[slack] Fatal:', err)
|
|
198
287
|
process.exit(1)
|
|
199
288
|
})
|
|
289
|
+
} else {
|
|
290
|
+
cli.clean_restart().catch((err) => {
|
|
291
|
+
console.error('[slack] Fatal:', err)
|
|
292
|
+
process.exit(1)
|
|
293
|
+
})
|
|
200
294
|
}
|
|
201
295
|
}
|
package/src/session-manager.ts
CHANGED
|
@@ -109,7 +109,7 @@ export async function launchSession(
|
|
|
109
109
|
const resumeSessionId = options?.sessionId
|
|
110
110
|
|
|
111
111
|
const escapedConfigPath = routingConfig.mcp_config_path.replace(/'/g, "'\\''")
|
|
112
|
-
let baseCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
|
|
112
|
+
let baseCmd = `SLACK_CHANNEL_BOT_SESSION=1 claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
|
|
113
113
|
|
|
114
114
|
if (routingConfig.append_system_prompt_file !== undefined) {
|
|
115
115
|
try {
|