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 +28 -4
- package/hooks/ask-relay.sh +5 -0
- package/hooks/permission-relay.sh +5 -0
- package/package.json +1 -1
- package/src/cli.ts +150 -12
- 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 +221 -169
- 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
|
|
|
@@ -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
|
|
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
|
|
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`.
|
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
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
|
-
//
|
|
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() +
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
console.error(' stop
|
|
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
|
-
|
|
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
|
|