claude-slack-channel-bots 0.1.4 → 0.2.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/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
 
@@ -200,7 +204,7 @@ 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`.
204
208
 
205
209
  ### `claude-slack-channel-bots clean_restart`
206
210
 
@@ -210,7 +214,7 @@ Gracefully exits all managed Claude Code sessions, then stops and starts the ser
210
214
  claude-slack-channel-bots clean_restart
211
215
  ```
212
216
 
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.
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.
214
218
 
215
219
  Behavior by case:
216
220
 
@@ -349,4 +353,4 @@ Check that the Slack app has interactivity enabled (Interactivity & Shortcuts
349
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`.
350
354
 
351
355
  **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`.
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`.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
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": {
7
- "claude-slack-channel-bots": "./src/cli.ts"
7
+ "claude-slack-channel-bots": "src/cli.ts"
8
8
  },
9
9
  "files": [
10
10
  "src/*.ts",
@@ -38,6 +38,6 @@
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "https://github.com/gabemahoney/claude-slack-channel-bots.git"
41
+ "url": "git+https://github.com/gabemahoney/claude-slack-channel-bots.git"
42
42
  }
43
43
  }
package/src/cli.ts CHANGED
@@ -11,11 +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 } from './tmux.ts'
17
+ import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning, sessionName as tmuxSessionName } from './tmux.ts'
18
18
  import { readSessions, type SessionsMap } from './sessions.ts'
19
+ import { loadConfig as configLoadConfig, type RoutingConfig } from './config.ts'
20
+ import { initLogging } from './logging.ts'
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Injectable dependency interface
@@ -44,8 +46,12 @@ export interface CliDeps {
44
46
  exit: (code: number) => never
45
47
  /** Returns true if a tmux session with the given name exists. */
46
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
47
53
  /** Sends keystrokes to the given tmux session. */
48
- sendKeys: (session: string, keys: string) => Promise<void>
54
+ sendKeys: (session: string, ...keys: string[]) => Promise<void>
49
55
  /** Returns true if a 'claude' process is running in the given tmux session. */
50
56
  isClaudeRunning: (session: string) => Promise<boolean>
51
57
  /** Kills the named tmux session. */
@@ -109,9 +115,11 @@ export function createCli(deps: CliDeps): CliHandlers {
109
115
  if (!process.env['_CLI_DAEMON_CHILD']) {
110
116
  // Parent: spawn a detached background child and exit
111
117
  const { spawn } = await import('child_process')
118
+ const logPath = join(stateDir, 'server.log')
119
+ const logFd = openSync(logPath, 'a')
112
120
  const child = spawn(process.execPath, [import.meta.filename, 'start'], {
113
121
  detached: true,
114
- stdio: 'ignore',
122
+ stdio: ['ignore', logFd, logFd],
115
123
  env: { ...process.env, _CLI_DAEMON_CHILD: '1' },
116
124
  })
117
125
  child.unref()
@@ -119,6 +127,9 @@ export function createCli(deps: CliDeps): CliHandlers {
119
127
  deps.exit(0)
120
128
  }
121
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
+
122
133
  // Child (daemon): start the server
123
134
  await deps.startServer()
124
135
  }
@@ -151,90 +162,121 @@ export function createCli(deps: CliDeps): CliHandlers {
151
162
  deps.exit(0)
152
163
  }
153
164
 
154
- // 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
155
175
  deps.kill(pid!, 'SIGTERM')
156
176
 
157
- const deadline = Date.now() + 5000
177
+ const deadline = Date.now() + stopTimeoutMs
158
178
  while (Date.now() < deadline) {
159
179
  await new Promise<void>((r) => setTimeout(r, 100))
160
180
  if (!deps.isProcessRunning(pid!)) {
181
+ try { deps.unlinkSync(pidFile) } catch { /* ignore */ }
161
182
  console.error('[slack] Server stopped.')
162
183
  deps.exit(0)
163
184
  }
164
185
  }
165
186
 
166
- console.error('[slack] Warning: server did not stop within 5s after SIGTERM.')
167
- 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)
168
204
  }
169
205
 
170
206
  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)
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
190
241
  }
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
242
 
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)
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
220
260
  }
221
- } catch (err) {
222
- console.error(`[slack] clean_restart: error polling channel=${channelId}:`, err)
223
261
  }
224
- }))
225
- }
226
262
 
227
- // Stop the server
228
- console.error('[slack] clean_restart: stopping server')
229
- deps.spawnSync(process.execPath, [process.argv[1], 'stop'])
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
+ }))
230
271
 
231
- // Start the server
272
+ // Phases 5-6: Start new server and exit
232
273
  console.error('[slack] clean_restart: starting server')
233
274
  const startResult = deps.spawnSync(process.execPath, [process.argv[1], 'start'])
234
275
  if (startResult.status !== 0) {
235
276
  console.error(`[slack] clean_restart: start failed with exit code ${startResult.status}`)
236
277
  deps.exit(startResult.status ?? 1)
237
278
  }
279
+ console.error('[slack] clean_restart: done')
238
280
  }
239
281
 
240
282
  return { start, stop, clean_restart }
@@ -267,8 +309,10 @@ if (import.meta.main) {
267
309
  resolveStateDir: defaultStateDir,
268
310
  startServer: async () => { const { main } = await import('./server.ts'); return main() },
269
311
  exit: (code) => process.exit(code),
312
+ loadConfig: () => configLoadConfig(),
313
+ sessionName: (cwd) => tmuxSessionName(cwd),
270
314
  hasSession: (name) => defaultTmuxClient.hasSession(name),
271
- sendKeys: (session, keys) => defaultTmuxClient.sendKeys(session, keys),
315
+ sendKeys: (session, ...keys) => defaultTmuxClient.sendKeys(session, ...keys),
272
316
  isClaudeRunning: (session) => tmuxIsClaudeRunning(session, defaultTmuxClient),
273
317
  killSession: (session) => defaultTmuxClient.killSession(session),
274
318
  readSessions: () => readSessions(),
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