claude-slack-channel-bots 0.1.3 → 0.2.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/README.md CHANGED
@@ -89,6 +89,8 @@ A skeleton file is created by postinstall. Populate it before running `start`.
89
89
  "port": 3100,
90
90
  "session_restart_delay": 60,
91
91
  "health_check_interval": 120,
92
+ "exit_timeout": 120,
93
+ "stop_timeout": 30,
92
94
  "mcp_config_path": "~/.claude/slack-mcp.json"
93
95
  }
94
96
  ```
@@ -98,12 +100,14 @@ A skeleton file is created by postinstall. Populate it before running `start`.
98
100
  | Field | Type | Default | Description |
99
101
  |---|---|---|---|
100
102
  | `routes` | object | required | Map of Slack channel ID → route entry. Each entry requires a `cwd` field: the working directory for that session. Used to identify sessions via `roots/list` after MCP handshake. `~` is expanded. Each `cwd` must be unique across all routes. |
101
- | `default_route` | string | — | CWD path to use when a message arrives on a channel with no explicit entry in `routes`. Must match an existing route `cwd`. |
103
+ | `default_route` | string | — | CWD path to use when a message arrives on a channel with no explicit entry in `routes`. Must match an existing route `cwd`. Channels that are in `routes` but whose session is not yet registered have their messages dropped — they do not fall back to `default_route`. |
102
104
  | `default_dm_session` | string | — | CWD path of the session that handles direct messages. Must match an existing route `cwd`. |
103
105
  | `bind` | string | `"127.0.0.1"` | Interface the HTTP server binds to. Use `"0.0.0.0"` to expose on all interfaces. |
104
106
  | `port` | number | `3100` | Port the HTTP server listens on. |
105
107
  | `session_restart_delay` | number | `60` | Seconds to wait before auto-restarting a dead session. Set to `0` to disable auto-restart. Must be non-negative. |
106
108
  | `health_check_interval` | number | `120` | Seconds between periodic liveness polls. Set to `0` to disable. Must be non-negative. |
109
+ | `exit_timeout` | number | `120` | Seconds to wait for a managed Claude Code session to exit gracefully during `clean_restart` before force-killing its tmux session. |
110
+ | `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
107
111
  | `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
108
112
  | `append_system_prompt_file` | string | — | Path to a file appended to every managed session's system prompt via `--append-system-prompt-file`. Missing file silently skipped. See `skills/EXAMPLE_CLAUDE.md` for a template. |
109
113
 
@@ -173,7 +177,7 @@ If you changed `port` or `bind` in `routing.json`, update the `url` here to matc
173
177
 
174
178
  ## CLI Reference
175
179
 
176
- The `claude-slack-channel-bots` binary exposes two subcommands.
180
+ The `claude-slack-channel-bots` binary exposes three subcommands.
177
181
 
178
182
  ### `claude-slack-channel-bots start`
179
183
 
@@ -200,7 +204,22 @@ Behavior by case:
200
204
 
201
205
  - **PID file missing:** prints `server is not running` and exits 0.
202
206
  - **Stale PID file** (process no longer running): removes the PID file, prints `server is not running (removed stale PID file)`, exits 0.
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).
207
+ - **Live process:** sends `SIGTERM`, polls for exit for up to `stop_timeout` seconds (default 30s). Prints `[slack] Server stopped.` on clean exit. Escalates to `SIGKILL` if the process does not exit within `stop_timeout`.
208
+
209
+ ### `claude-slack-channel-bots clean_restart`
210
+
211
+ Gracefully exits all managed Claude Code sessions, then stops and starts the server.
212
+
213
+ ```sh
214
+ claude-slack-channel-bots clean_restart
215
+ ```
216
+
217
+ 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 `exit_timeout` seconds (default 120s), 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.
218
+
219
+ Behavior by case:
220
+
221
+ - **No sessions.json or no sessions:** skips the shutdown phase and proceeds directly to stop/start.
222
+ - **Server already stopped:** `stop` reports `server is not running`; `start` then brings up a fresh server.
204
223
 
205
224
  ### PID file
206
225
 
@@ -250,6 +269,8 @@ When Claude Code requires tool approval, the permission relay surfaces an intera
250
269
 
251
270
  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
271
 
272
+ 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.
273
+
253
274
  Both hooks use a **two-phase long-poll protocol**:
254
275
 
255
276
  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`.
@@ -326,7 +347,10 @@ After inviting the bot to a channel, Slack may not deliver messages until the bo
326
347
  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.
327
348
 
328
349
  **Permission relay not working**
329
- 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.
350
+ 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.
330
351
 
331
352
  **Session not restarting after crash**
332
353
  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`.
354
+
355
+ **Session stuck during clean_restart**
356
+ If a session does not exit within `exit_timeout` seconds (default 120s), `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.3",
3
+ "version": "0.2.0",
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": {
package/src/cli.ts CHANGED
@@ -11,9 +11,13 @@
11
11
 
12
12
  import { homedir } from 'os'
13
13
  import { join, resolve } from 'path'
14
- import { existsSync, readFileSync, unlinkSync } from 'fs'
14
+ import { existsSync, openSync, readFileSync, unlinkSync } from 'fs'
15
15
  import { spawnSync } from 'child_process'
16
16
  import { isProcessRunning } from './pid.ts'
17
+ import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning, sessionName as tmuxSessionName } from './tmux.ts'
18
+ import { readSessions, type SessionsMap } from './sessions.ts'
19
+ import { loadConfig as configLoadConfig, type RoutingConfig } from './config.ts'
20
+ import { initLogging } from './logging.ts'
17
21
 
18
22
  // ---------------------------------------------------------------------------
19
23
  // Injectable dependency interface
@@ -40,6 +44,20 @@ export interface CliDeps {
40
44
  startServer: () => Promise<void>
41
45
  /** Exit the process. */
42
46
  exit: (code: number) => never
47
+ /** Returns true if a tmux session with the given name exists. */
48
+ hasSession: (name: string) => Promise<boolean>
49
+ /** Load the routing configuration. */
50
+ loadConfig: () => RoutingConfig
51
+ /** Returns the canonical tmux session name for a given working directory path. */
52
+ sessionName: (cwd: string) => string
53
+ /** Sends keystrokes to the given tmux session. */
54
+ sendKeys: (session: string, ...keys: string[]) => Promise<void>
55
+ /** Returns true if a 'claude' process is running in the given tmux session. */
56
+ isClaudeRunning: (session: string) => Promise<boolean>
57
+ /** Kills the named tmux session. */
58
+ killSession: (session: string) => Promise<void>
59
+ /** Read the sessions registry. */
60
+ readSessions: () => SessionsMap
43
61
  }
44
62
 
45
63
  // ---------------------------------------------------------------------------
@@ -58,6 +76,7 @@ function defaultStateDir(): string {
58
76
  export interface CliHandlers {
59
77
  start: () => Promise<void>
60
78
  stop: () => Promise<void>
79
+ clean_restart: () => Promise<void>
61
80
  }
62
81
 
63
82
  /**
@@ -96,9 +115,11 @@ export function createCli(deps: CliDeps): CliHandlers {
96
115
  if (!process.env['_CLI_DAEMON_CHILD']) {
97
116
  // Parent: spawn a detached background child and exit
98
117
  const { spawn } = await import('child_process')
118
+ const logPath = join(stateDir, 'server.log')
119
+ const logFd = openSync(logPath, 'a')
99
120
  const child = spawn(process.execPath, [import.meta.filename, 'start'], {
100
121
  detached: true,
101
- stdio: 'ignore',
122
+ stdio: ['ignore', logFd, logFd],
102
123
  env: { ...process.env, _CLI_DAEMON_CHILD: '1' },
103
124
  })
104
125
  child.unref()
@@ -106,6 +127,9 @@ export function createCli(deps: CliDeps): CliHandlers {
106
127
  deps.exit(0)
107
128
  }
108
129
 
130
+ // Child (daemon): redirect stderr/stdout to server.log
131
+ try { initLogging(join(stateDir, 'server.log')) } catch { /* best-effort: log redirect failure is non-fatal */ }
132
+
109
133
  // Child (daemon): start the server
110
134
  await deps.startServer()
111
135
  }
@@ -138,23 +162,124 @@ export function createCli(deps: CliDeps): CliHandlers {
138
162
  deps.exit(0)
139
163
  }
140
164
 
141
- // Live process send SIGTERM and poll until exit or 5s timeout
165
+ // Load stop_timeout from config (fall back to 30s if unavailable)
166
+ let stopTimeoutMs = 30_000
167
+ try {
168
+ const config = deps.loadConfig()
169
+ if (typeof config.stop_timeout === 'number') {
170
+ stopTimeoutMs = config.stop_timeout * 1000
171
+ }
172
+ } catch { /* use default */ }
173
+
174
+ // Live process — send SIGTERM and poll until exit or stop_timeout
142
175
  deps.kill(pid!, 'SIGTERM')
143
176
 
144
- const deadline = Date.now() + 5000
177
+ const deadline = Date.now() + stopTimeoutMs
145
178
  while (Date.now() < deadline) {
146
179
  await new Promise<void>((r) => setTimeout(r, 100))
147
180
  if (!deps.isProcessRunning(pid!)) {
181
+ try { deps.unlinkSync(pidFile) } catch { /* ignore */ }
148
182
  console.error('[slack] Server stopped.')
149
183
  deps.exit(0)
150
184
  }
151
185
  }
152
186
 
153
- console.error('[slack] Warning: server did not stop within 5s after SIGTERM.')
154
- deps.exit(0)
187
+ // SIGTERM timed out escalate to SIGKILL
188
+ console.error(`[slack] Warning: server did not stop within ${stopTimeoutMs / 1000}s after SIGTERM — sending SIGKILL.`)
189
+ deps.kill(pid!, 'SIGKILL')
190
+
191
+ // Poll briefly (~2s) to confirm death after SIGKILL
192
+ const killDeadline = Date.now() + 2000
193
+ while (Date.now() < killDeadline) {
194
+ await new Promise<void>((r) => setTimeout(r, 100))
195
+ if (!deps.isProcessRunning(pid!)) {
196
+ try { deps.unlinkSync(pidFile) } catch { /* ignore */ }
197
+ console.error('[slack] Server killed.')
198
+ deps.exit(0)
199
+ }
200
+ }
201
+
202
+ console.error('[slack] Warning: server did not die after SIGKILL.')
203
+ deps.exit(1)
155
204
  }
156
205
 
157
- return { start, stop }
206
+ async function clean_restart(): Promise<void> {
207
+ try { initLogging(join(deps.resolveStateDir(), 'clean_restart.log')) } catch { /* best-effort */ }
208
+
209
+ // Phase 1: Load config
210
+ let config: RoutingConfig
211
+ try {
212
+ config = deps.loadConfig()
213
+ } catch (err) {
214
+ console.error('[slack] clean_restart: failed to load config:', err)
215
+ deps.exit(1)
216
+ }
217
+ const { routes, exit_timeout } = config!
218
+
219
+ // Phase 2: Stop the server daemon
220
+ console.error('[slack] clean_restart: stopping server')
221
+ const stopResult = deps.spawnSync(process.execPath, [process.argv[1], 'stop'])
222
+ if (stopResult.status !== 0) {
223
+ console.error(`[slack] clean_restart: stop returned non-zero exit code: ${stopResult.status}`)
224
+ }
225
+
226
+ // Phases 3-4: Exit Claude sessions concurrently
227
+ await Promise.allSettled(Object.entries(routes).map(async ([channelId, route]) => {
228
+ const name = deps.sessionName(route.cwd)
229
+ try {
230
+ // Phase 3: Check session exists
231
+ const exists = await deps.hasSession(name)
232
+ if (!exists) {
233
+ console.error(`[slack] clean_restart: session not found for channel=${channelId} session=${name}`)
234
+ return
235
+ }
236
+
237
+ const claudeRunning = await deps.isClaudeRunning(name)
238
+ if (!claudeRunning) {
239
+ console.error(`[slack] clean_restart: Claude not running for channel=${channelId} session=${name}`)
240
+ return
241
+ }
242
+
243
+ // Phase 4: Send /exit atomically
244
+ await deps.sendKeys(name, '/exit', 'Enter')
245
+
246
+ // Poll with exponential backoff until exit or timeout
247
+ const timeoutMs = exit_timeout * 1000
248
+ const start = Date.now()
249
+ let delay = 500
250
+ const maxDelay = 5_000
251
+
252
+ while (Date.now() - start < timeoutMs) {
253
+ await new Promise<void>((r) => setTimeout(r, delay))
254
+ delay = Math.min(delay * 2, maxDelay)
255
+ const running = await deps.isClaudeRunning(name)
256
+ if (!running) {
257
+ const elapsed = Date.now() - start
258
+ console.error(`[slack] clean_restart: channel=${channelId} session=${name} exited cleanly in ${elapsed}ms`)
259
+ return
260
+ }
261
+ }
262
+
263
+ // Timeout — force kill
264
+ const elapsed = Date.now() - start
265
+ await deps.killSession(name)
266
+ console.error(`[slack] clean_restart: channel=${channelId} session=${name} force-killed after ${elapsed}ms`)
267
+ } catch (err) {
268
+ console.error(`[slack] clean_restart: error processing channel=${channelId} session=${name}:`, err)
269
+ }
270
+ }))
271
+
272
+ // Phases 5-6: Start new server and exit
273
+ console.error('[slack] clean_restart: starting server')
274
+ const startResult = deps.spawnSync(process.execPath, [process.argv[1], 'start'])
275
+ if (startResult.status !== 0) {
276
+ console.error(`[slack] clean_restart: start failed with exit code ${startResult.status}`)
277
+ deps.exit(startResult.status ?? 1)
278
+ }
279
+ console.error('[slack] clean_restart: done')
280
+ }
281
+
282
+ return { start, stop, clean_restart }
158
283
  }
159
284
 
160
285
  // ---------------------------------------------------------------------------
@@ -164,11 +289,12 @@ export function createCli(deps: CliDeps): CliHandlers {
164
289
  if (import.meta.main) {
165
290
  const subcommand = process.argv[2]
166
291
 
167
- if (subcommand !== 'start' && subcommand !== 'stop') {
168
- console.error('Usage: cli.ts <start|stop>')
292
+ if (subcommand !== 'start' && subcommand !== 'stop' && subcommand !== 'clean_restart') {
293
+ console.error('Usage: cli.ts <start|stop|clean_restart>')
169
294
  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')
295
+ console.error(' start Validate prerequisites and start the server in the background')
296
+ console.error(' stop Send SIGTERM to a running server')
297
+ console.error(' clean_restart Exit all managed sessions, then stop and start the server')
172
298
  process.exit(1)
173
299
  }
174
300
 
@@ -183,6 +309,13 @@ if (import.meta.main) {
183
309
  resolveStateDir: defaultStateDir,
184
310
  startServer: async () => { const { main } = await import('./server.ts'); return main() },
185
311
  exit: (code) => process.exit(code),
312
+ loadConfig: () => configLoadConfig(),
313
+ sessionName: (cwd) => tmuxSessionName(cwd),
314
+ hasSession: (name) => defaultTmuxClient.hasSession(name),
315
+ sendKeys: (session, ...keys) => defaultTmuxClient.sendKeys(session, ...keys),
316
+ isClaudeRunning: (session) => tmuxIsClaudeRunning(session, defaultTmuxClient),
317
+ killSession: (session) => defaultTmuxClient.killSession(session),
318
+ readSessions: () => readSessions(),
186
319
  }
187
320
 
188
321
  const cli = createCli(realDeps)
@@ -192,10 +325,15 @@ if (import.meta.main) {
192
325
  console.error('[slack] Fatal:', err)
193
326
  process.exit(1)
194
327
  })
195
- } else {
328
+ } else if (subcommand === 'stop') {
196
329
  cli.stop().catch((err) => {
197
330
  console.error('[slack] Fatal:', err)
198
331
  process.exit(1)
199
332
  })
333
+ } else {
334
+ cli.clean_restart().catch((err) => {
335
+ console.error('[slack] Fatal:', err)
336
+ process.exit(1)
337
+ })
200
338
  }
201
339
  }
package/src/config.ts CHANGED
@@ -37,6 +37,8 @@ export interface RoutingConfigInput {
37
37
  port?: number
38
38
  session_restart_delay?: number
39
39
  health_check_interval?: number
40
+ exit_timeout?: number
41
+ stop_timeout?: number
40
42
  mcp_config_path?: string
41
43
  append_system_prompt_file?: string
42
44
  }
@@ -50,6 +52,8 @@ export interface RoutingConfig {
50
52
  port: number
51
53
  session_restart_delay: number
52
54
  health_check_interval: number
55
+ exit_timeout: number
56
+ stop_timeout: number
53
57
  mcp_config_path: string
54
58
  append_system_prompt_file?: string
55
59
  }
@@ -71,6 +75,8 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
71
75
  port: input.port ?? 3100,
72
76
  session_restart_delay: input.session_restart_delay ?? 60,
73
77
  health_check_interval: input.health_check_interval ?? 120,
78
+ exit_timeout: input.exit_timeout ?? 120,
79
+ stop_timeout: input.stop_timeout ?? 30,
74
80
  mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
75
81
  append_system_prompt_file: input.append_system_prompt_file,
76
82
  }
@@ -132,6 +138,20 @@ export function validateConfig(config: RoutingConfig): void {
132
138
  )
133
139
  }
134
140
 
141
+ // exit_timeout must not be negative
142
+ if (config.exit_timeout < 0) {
143
+ throw new Error(
144
+ 'Routing config validation error: exit_timeout must be a non-negative number.',
145
+ )
146
+ }
147
+
148
+ // stop_timeout must not be negative
149
+ if (config.stop_timeout < 0) {
150
+ throw new Error(
151
+ 'Routing config validation error: stop_timeout must be a non-negative number.',
152
+ )
153
+ }
154
+
135
155
  // default_dm_session must reference an existing route CWD
136
156
  if (config.default_dm_session !== undefined) {
137
157
  if (!seen.has(config.default_dm_session)) {
package/src/lib.ts CHANGED
@@ -254,8 +254,10 @@ export async function gate(event: unknown, opts: GateOptions): Promise<GateResul
254
254
 
255
255
  if (!policy && !isRouted) return { action: 'drop' }
256
256
 
257
- // Use the explicit policy if present, otherwise fall back to permissive defaults
258
- const effectivePolicy = policy ?? { requireMention: false, allowFrom: [] }
257
+ // Use the explicit policy if present, otherwise fall back to permissive defaults.
258
+ // Merge with defaults so partial entries (e.g. missing allowFrom) don't crash.
259
+ const defaults = { requireMention: false, allowFrom: [] as string[] }
260
+ const effectivePolicy = policy ? { ...defaults, ...policy } : defaults
259
261
 
260
262
  if (effectivePolicy.allowFrom.length > 0 && !effectivePolicy.allowFrom.includes(ev['user'] as string)) {
261
263
  return { action: 'drop' }
package/src/logging.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { closeSync, openSync, writeSync } from 'node:fs'
2
+
3
+ // --- Logging ---
4
+
5
+ let fd: number | null = null
6
+ const originalConsoleError = console.error
7
+ const originalConsoleLog = console.log
8
+
9
+ function formatArgs(args: unknown[]): string {
10
+ return args
11
+ .map(arg => {
12
+ if (typeof arg === 'string') return arg
13
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack ?? ''}`
14
+ if (arg !== null && typeof arg === 'object') return JSON.stringify(arg)
15
+ return String(arg)
16
+ })
17
+ .join(' ')
18
+ }
19
+
20
+ function makeLogFn(original: (...args: unknown[]) => void): (...args: unknown[]) => void {
21
+ return (...args: unknown[]): void => {
22
+ const timestamp = new Date().toISOString()
23
+ const message = formatArgs(args)
24
+ const line = `[${timestamp}] ${message}`
25
+ if (fd !== null) {
26
+ try {
27
+ writeSync(fd, line + '\n')
28
+ return
29
+ } catch {
30
+ // fall through to original
31
+ }
32
+ }
33
+ original(...args)
34
+ }
35
+ }
36
+
37
+ export function initLogging(logFilePath: string): void {
38
+ if (fd !== null) {
39
+ try { closeSync(fd) } catch { /* ignore */ }
40
+ }
41
+ fd = openSync(logFilePath, 'a')
42
+ console.error = makeLogFn(originalConsoleError) as typeof console.error
43
+ console.log = makeLogFn(originalConsoleLog) as typeof console.log
44
+ }
package/src/registry.ts CHANGED
@@ -182,12 +182,30 @@ export function getAllPendingSessions(): PendingSessionEntry[] {
182
182
  * Remove a session from the registry by its MCP session ID.
183
183
  * Marks the entry as disconnected before removal.
184
184
  * Returns the CWD if found, undefined otherwise.
185
+ *
186
+ * Guards against a race condition where a reconnect registers a new session
187
+ * for the same CWD before the old SSE abort fires. If the current registry
188
+ * entry's transport belongs to a different MCP session, the old mapping is
189
+ * cleaned up but the new session is left intact.
185
190
  */
186
191
  export function unregisterByMcpSessionId(mcpSessionId: string): string | undefined {
187
192
  const cwd = mcpSessionIdToCwd.get(mcpSessionId)
188
193
  if (!cwd) return undefined
194
+
195
+ // Always clean up the stale MCP ID → CWD mapping
196
+ mcpSessionIdToCwd.delete(mcpSessionId)
197
+
189
198
  const entry = registry.get(cwd)
190
- if (entry) entry.connected = false
199
+ if (!entry) return cwd
200
+
201
+ // If a newer session has already replaced this one in the registry,
202
+ // don't destroy it — just clean up the old mapping and return.
203
+ if (entry.transport.sessionId !== mcpSessionId) {
204
+ console.error(`[registry] Skipping unregister for stale MCP session "${mcpSessionId}" — CWD "${cwd}" already has a newer session`)
205
+ return undefined
206
+ }
207
+
208
+ entry.connected = false
191
209
  unregisterSession(cwd)
192
210
  return cwd
193
211
  }
package/src/restart.ts CHANGED
@@ -13,6 +13,9 @@
13
13
 
14
14
  export interface RestartDeps {
15
15
  isSessionAlive(channelId: string): Promise<boolean>
16
+ /** Check if the session already has a live MCP connection in the registry. */
17
+ isSessionConnected(channelId: string): boolean
18
+ reconnectSession(channelId: string): Promise<void>
16
19
  killSession(channelId: string): Promise<void>
17
20
  launchSession(channelId: string, cwd: string, sessionId?: string): Promise<boolean>
18
21
  getRestartDelay(): number
@@ -96,7 +99,18 @@ export function scheduleRestart(channelId: string, cwd: string, sessionId?: stri
96
99
  }
97
100
 
98
101
  if (alive) {
99
- console.error(`[slack] Session already live skipping restart for channel=${channelId}`)
102
+ // If the session already re-established its MCP connection (e.g. Claude
103
+ // Code refreshed the SSE stream on its own), skip the reconnect.
104
+ if (deps.isSessionConnected(channelId)) {
105
+ console.error(`[slack] Session already reconnected — skipping restart for channel=${channelId}`)
106
+ return
107
+ }
108
+ console.error(`[slack] Session alive but disconnected — reconnecting MCP for channel=${channelId}`)
109
+ try {
110
+ await deps.reconnectSession(channelId)
111
+ } catch (err) {
112
+ console.error(`[slack] restart: reconnectSession failed for channel=${channelId}:`, err)
113
+ }
100
114
  return
101
115
  }
102
116