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 +8 -4
- package/package.json +3 -3
- package/src/cli.ts +108 -64
- package/src/config.ts +20 -0
- package/src/lib.ts +4 -2
- package/src/logging.ts +44 -0
- package/src/registry.ts +19 -1
- package/src/restart.ts +15 -1
- package/src/server.ts +77 -30
- package/src/session-manager.ts +220 -168
- package/src/sessions.ts +20 -1
- package/src/tmux.ts +21 -9
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
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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
|
-
//
|
|
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() +
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|