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 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 two subcommands.
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-channel-access` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
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`.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Multi-session Slack-to-Claude bridge — run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return { start, stop }
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 Validate prerequisites and start the server in the background')
171
- console.error(' stop Send SIGTERM to a running server')
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
  }
@@ -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 {