claude-slack-channel-bots 0.0.4 → 0.1.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
@@ -105,6 +105,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
105
105
  | `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
106
  | `health_check_interval` | number | `120` | Seconds between periodic liveness polls. Set to `0` to disable. Must be non-negative. |
107
107
  | `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
108
+ | `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. |
108
109
 
109
110
  ---
110
111
 
@@ -185,7 +186,7 @@ Checks prerequisites, then daemonizes the server.
185
186
  3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
186
187
  4. `routing.json` exists at `STATE_DIR/routing.json` — fails with the full path if not found.
187
188
 
188
- If all checks pass, the parent process spawns a detached child process and exits immediately, printing the child PID. The child starts the server and writes its PID to `STATE_DIR/server.pid`.
189
+ If all checks pass, the parent process spawns a detached child process and exits immediately, printing the child PID. The child starts the server and writes its PID to `STATE_DIR/server.pid`. Conversation context is preserved across server restarts when possible.
189
190
 
190
191
  ```
191
192
  [slack] Server starting in background (PID 12345)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.0.4",
3
+ "version": "0.1.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": {
@@ -0,0 +1,15 @@
1
+ # Role
2
+ You are an orchestration agent that bridges between the User and worker-Claude sessions you manage.
3
+
4
+ # Communication with the User
5
+ The User only communicate to you via Slack so always use the mcp__slack-channel-router__reply tool to send messages.
6
+ **Important**: Nothing you send to the TUI will be seen by the User
7
+
8
+ # Development process
9
+ You use the development process defined in the readme in ~/projects/apiary/README.MD.
10
+ You use the Apiary skills listed in that project to manage software development from ideation to completion.
11
+
12
+ # Spawning workers
13
+ You do not do any work yourself, you always spawn Claude sessions as workers to do the work.
14
+ This keeps you available to interact with the User and orchestrate the work.
15
+ You use `waggle` to spawn workers and monitor their status.
@@ -225,7 +225,49 @@ their defaults unless asked.
225
225
 
226
226
  ---
227
227
 
228
- ### Step 5 — Check access.json
228
+ ### Step 5 — Configure custom CLAUDE.md (optional)
229
+
230
+ Worker sessions launched by the server can receive a custom system-prompt
231
+ append via `append_system_prompt_file` in `routing.json`. This is useful for
232
+ giving all workers project-specific instructions: communication rules,
233
+ development process, how to spawn sub-workers, and so on.
234
+
235
+ An example template is included with the package. Show the operator where it
236
+ lives:
237
+
238
+ ```bash
239
+ ls "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
240
+ || echo "NOT_FOUND"
241
+ ```
242
+
243
+ If the file is found, print its path so the operator can inspect it as a
244
+ starting point.
245
+
246
+ Ask the operator: **"Do you want to configure a custom CLAUDE.md file for
247
+ worker sessions?"**
248
+
249
+ **If yes:**
250
+
251
+ 1. Prompt for the absolute path to their CLAUDE.md file.
252
+ 2. Verify the file exists:
253
+ ```bash
254
+ test -f "<provided-path>" && echo "ok" || echo "not found"
255
+ ```
256
+ Expand `~` before checking. If the file does not exist, warn the operator
257
+ and re-prompt until a valid path is given or they choose to skip.
258
+ 3. Read `routing.json`, add or update the top-level field:
259
+ ```json
260
+ "append_system_prompt_file": "<provided-path>"
261
+ ```
262
+ Write the updated file, preserving all other fields.
263
+
264
+ **If skipped:**
265
+
266
+ Do not write `append_system_prompt_file` to `routing.json`. Move on.
267
+
268
+ ---
269
+
270
+ ### Step 6 — Check access.json
229
271
 
230
272
  ```bash
231
273
  STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
@@ -264,7 +306,7 @@ Do not modify `access.json` during setup unless the user asks to.
264
306
 
265
307
  ---
266
308
 
267
- ### Step 6 — Check hooks
309
+ ### Step 7 — Check hooks
268
310
 
269
311
  Check whether the relay hooks exist and are executable:
270
312
 
@@ -312,7 +354,7 @@ chmod +x ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
312
354
 
313
355
  ---
314
356
 
315
- ### Step 7 — Check Claude Code settings.json for hook entries
357
+ ### Step 8 — Check Claude Code settings.json for hook entries
316
358
 
317
359
  Read `~/.claude/settings.json` and check whether the `PermissionRequest` and
318
360
  `PreToolUse` hook entries for the relay scripts are present.
@@ -365,13 +407,14 @@ If the user agrees, make the targeted edits, preserving all existing content.
365
407
 
366
408
  ---
367
409
 
368
- ### Step 8 — Summary
410
+ ### Step 9 — Summary
369
411
 
370
412
  Print a final summary of what was checked and configured:
371
413
 
372
414
  - Environment variables: set / missing
373
415
  - Token format: valid / invalid
374
416
  - routing.json: populated (N routes) / skeleton
417
+ - append_system_prompt_file: configured / skipped
375
418
  - access.json: present / missing
376
419
  - permission-relay.sh hook: present and executable / missing
377
420
  - ask-relay.sh hook: present and executable / missing
package/src/cli.ts CHANGED
File without changes
package/src/config.ts CHANGED
@@ -12,6 +12,12 @@ import { readFileSync } from 'fs'
12
12
  import { homedir } from 'os'
13
13
  import { resolve } from 'path'
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const MCP_SERVER_NAME = 'slack-channel-router'
20
+
15
21
  // ---------------------------------------------------------------------------
16
22
  // Types
17
23
  // ---------------------------------------------------------------------------
@@ -32,6 +38,7 @@ export interface RoutingConfigInput {
32
38
  session_restart_delay?: number
33
39
  health_check_interval?: number
34
40
  mcp_config_path?: string
41
+ append_system_prompt_file?: string
35
42
  }
36
43
 
37
44
  /** Validated, fully-resolved routing configuration with all defaults applied. */
@@ -44,6 +51,7 @@ export interface RoutingConfig {
44
51
  session_restart_delay: number
45
52
  health_check_interval: number
46
53
  mcp_config_path: string
54
+ append_system_prompt_file?: string
47
55
  }
48
56
 
49
57
  // ---------------------------------------------------------------------------
@@ -64,6 +72,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
64
72
  session_restart_delay: input.session_restart_delay ?? 60,
65
73
  health_check_interval: input.health_check_interval ?? 120,
66
74
  mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
75
+ append_system_prompt_file: input.append_system_prompt_file,
67
76
  }
68
77
  }
69
78
 
@@ -160,6 +169,9 @@ export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
160
169
  ? resolve(expandTilde(withDefaults.default_dm_session))
161
170
  : undefined,
162
171
  mcp_config_path: resolve(expandTilde(withDefaults.mcp_config_path)),
172
+ append_system_prompt_file: withDefaults.append_system_prompt_file !== undefined
173
+ ? resolve(expandTilde(withDefaults.append_system_prompt_file))
174
+ : undefined,
163
175
  }
164
176
 
165
177
  validateConfig(config)
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs'
13
13
  import { homedir } from 'os'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultAccess } from './lib.ts'
16
+ import { MCP_SERVER_NAME } from './config.ts'
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Types
@@ -70,7 +71,7 @@ export function runPostinstall(options: PostinstallOptions = {}): void {
70
71
  } else {
71
72
  const skeleton = {
72
73
  mcpServers: {
73
- 'slack-channel-router': {
74
+ [MCP_SERVER_NAME]: {
74
75
  type: 'http',
75
76
  url: 'http://127.0.0.1:3100/mcp',
76
77
  },
package/src/registry.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  CallToolRequestSchema,
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js'
17
- import type { RoutingConfig } from './config.ts'
17
+ import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // Types
@@ -335,7 +335,7 @@ export function createSessionServer(
335
335
  const { web, assertOutboundAllowed, assertSendable, getAccess, resolveUserName, inboxDir, consumeAck } = deps
336
336
 
337
337
  const server = new Server(
338
- { name: 'slack-channel-router', version: '0.1.0' },
338
+ { name: MCP_SERVER_NAME, version: '0.1.0' },
339
339
  {
340
340
  capabilities: {
341
341
  experimental: { 'claude/channel': {} },
package/src/restart.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  export interface RestartDeps {
15
15
  isSessionAlive(channelId: string): Promise<boolean>
16
16
  killSession(channelId: string): Promise<void>
17
- launchSession(channelId: string, cwd: string): Promise<boolean>
17
+ launchSession(channelId: string, cwd: string, sessionId?: string): Promise<boolean>
18
18
  getRestartDelay(): number
19
19
  isShuttingDown(): boolean
20
20
  }
@@ -46,7 +46,7 @@ export function initRestart(d: RestartDeps): void {
46
46
  // scheduleRestart
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- export function scheduleRestart(channelId: string, cwd: string): void {
49
+ export function scheduleRestart(channelId: string, cwd: string, sessionId?: string): void {
50
50
  if (!deps) {
51
51
  console.error('[slack] scheduleRestart: deps not initialized — skipping')
52
52
  return
@@ -109,7 +109,7 @@ export function scheduleRestart(channelId: string, cwd: string): void {
109
109
 
110
110
  let ok: boolean
111
111
  try {
112
- ok = await deps.launchSession(channelId, cwd)
112
+ ok = await deps.launchSession(channelId, cwd, sessionId)
113
113
  } catch (err) {
114
114
  console.error(`[slack] restart: launchSession threw for channel=${channelId}:`, err)
115
115
  ok = false
package/src/server.ts CHANGED
@@ -18,7 +18,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
18
18
  import { SocketModeClient } from '@slack/socket-mode'
19
19
  import { WebClient } from '@slack/web-api'
20
20
  import { homedir } from 'os'
21
- import { join, resolve } from 'path'
21
+ import { join, resolve, relative, isAbsolute } from 'path'
22
22
  import { fileURLToPath } from 'url'
23
23
  import {
24
24
  readFileSync,
@@ -37,7 +37,7 @@ import {
37
37
  type Access,
38
38
  type GateResult,
39
39
  } from './lib.ts'
40
- import { loadConfig, expandTilde, type RoutingConfig } from './config.ts'
40
+ import { loadConfig, expandTilde, type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
41
41
  import { readSessions, writeSessions } from './sessions.ts'
42
42
  import { defaultTmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
43
43
  import { startupSessionManager, launchSession } from './session-manager.ts'
@@ -73,6 +73,30 @@ import {
73
73
  // Re-export constants so they stay in one place (lib.ts)
74
74
  export { MAX_PENDING, MAX_PAIRING_REPLIES, PAIRING_EXPIRY_MS } from './lib.ts'
75
75
 
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Find the channel whose route CWD is the closest ancestor of (or equal to)
82
+ * the given absolute path. Returns undefined when no route matches.
83
+ */
84
+ function findChannelByCwd(absoluteCwd: string, routes: RoutingConfig['routes']): string | undefined {
85
+ let bestChannel: string | undefined
86
+ let bestLen = -1
87
+ for (const [channelId, route] of Object.entries(routes)) {
88
+ const routeCwd = resolve(expandTilde(route.cwd))
89
+ const rel = relative(routeCwd, absoluteCwd)
90
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
91
+ if (routeCwd.length > bestLen) {
92
+ bestLen = routeCwd.length
93
+ bestChannel = channelId
94
+ }
95
+ }
96
+ }
97
+ return bestChannel
98
+ }
99
+
76
100
  // ---------------------------------------------------------------------------
77
101
  // Constants
78
102
  // ---------------------------------------------------------------------------
@@ -296,7 +320,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
296
320
  : undefined
297
321
  if (channelId) {
298
322
  console.error(`[slack] Session disconnected: channel=${channelId} cwd="${cwd}"`)
299
- scheduleRestart(channelId, cwd)
323
+ scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
300
324
  } else {
301
325
  console.error(`[slack] Session disconnected: cwd="${cwd}"`)
302
326
  }
@@ -1003,12 +1027,10 @@ export async function main(): Promise<void> {
1003
1027
  )
1004
1028
  }
1005
1029
 
1006
- // Normalize CWD and find matching channel
1030
+ // Find the most specific route whose CWD is an ancestor of (or equal to) the request CWD
1007
1031
  const normalizedCwd = resolve(expandTilde(cwd))
1008
1032
  const matchedChannelId = routingConfig
1009
- ? Object.entries(routingConfig.routes).find(
1010
- ([, route]) => resolve(expandTilde(route.cwd)) === normalizedCwd,
1011
- )?.[0]
1033
+ ? findChannelByCwd(normalizedCwd, routingConfig.routes)
1012
1034
  : undefined
1013
1035
 
1014
1036
  if (!matchedChannelId) {
@@ -1150,11 +1172,10 @@ export async function main(): Promise<void> {
1150
1172
  )
1151
1173
  }
1152
1174
 
1175
+ // Find the most specific route whose CWD is an ancestor of (or equal to) the request CWD
1153
1176
  const normalizedCwd = resolve(expandTilde(cwd as string))
1154
1177
  const matchedChannelId = routingConfig
1155
- ? Object.entries(routingConfig.routes).find(
1156
- ([, route]) => resolve(expandTilde(route.cwd)) === normalizedCwd,
1157
- )?.[0]
1178
+ ? findChannelByCwd(normalizedCwd, routingConfig.routes)
1158
1179
  : undefined
1159
1180
 
1160
1181
  if (!matchedChannelId) {
@@ -1266,7 +1287,7 @@ export async function main(): Promise<void> {
1266
1287
  : undefined
1267
1288
  if (channelId) {
1268
1289
  console.error(`[slack] Session disconnected (SSE abort): channel=${channelId} cwd="${cwd}"`)
1269
- scheduleRestart(channelId, cwd)
1290
+ scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
1270
1291
  } else {
1271
1292
  console.error(`[slack] Session disconnected (SSE abort): cwd="${cwd}"`)
1272
1293
  }
@@ -1288,10 +1309,10 @@ export async function main(): Promise<void> {
1288
1309
  console.error(`[slack] MCP server listening on http://${mcpHost}:${mcpPort}/mcp`)
1289
1310
  console.error('')
1290
1311
  console.error('Save this to ~/.claude/slack-mcp.json:')
1291
- console.error(JSON.stringify({ mcpServers: { 'slack-channel-router': { type: 'http', url: `http://${mcpHost}:${mcpPort}/mcp` } } }, null, 2))
1312
+ console.error(JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { type: 'http', url: `http://${mcpHost}:${mcpPort}/mcp` } } }, null, 2))
1292
1313
  console.error('')
1293
1314
  console.error('Then launch Claude from a project directory with:')
1294
- console.error(' claude --mcp-config ~/.claude/slack-mcp.json --dangerously-load-development-channels server:slack-channel-router')
1315
+ console.error(` claude --mcp-config ~/.claude/slack-mcp.json --dangerously-load-development-channels server:${MCP_SERVER_NAME}`)
1295
1316
  console.error('')
1296
1317
 
1297
1318
  // Initialize restart module with adapters bridging tmux + session-manager
@@ -1311,9 +1332,13 @@ export async function main(): Promise<void> {
1311
1332
  const exists = await defaultTmuxClient.hasSession(name)
1312
1333
  if (exists) await defaultTmuxClient.killSession(name)
1313
1334
  },
1314
- launchSession: (channelId, cwd) => {
1335
+ launchSession: (channelId, cwd, sessionId) => {
1315
1336
  if (!routingConfig) return Promise.resolve(false)
1316
- return launchSession(channelId, cwd, routingConfig, defaultTmuxClient, readSessions, writeSessions)
1337
+ const resolvedSessionId = sessionId ?? readSessions()[channelId]?.sessionId
1338
+ return launchSession(
1339
+ channelId, cwd, routingConfig, defaultTmuxClient, readSessions, writeSessions,
1340
+ resolvedSessionId !== undefined ? { sessionId: resolvedSessionId } : undefined,
1341
+ )
1317
1342
  },
1318
1343
  getRestartDelay: () => routingConfig?.session_restart_delay ?? 60,
1319
1344
  isShuttingDown: () => shuttingDown,
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * session-manager.ts — Startup orchestration for tmux-managed Claude Code sessions.
3
3
  *
4
- * Handles two cases per route at server startup:
5
- * exists — tmux session foundkill and relaunch
6
- * missing no tmux session → launch fresh
4
+ * Handles three cases per route at server startup:
5
+ * reconnect — tmux session exists AND Claude is running send /mcp reconnect, do not relaunch
6
+ * resume — dead or missing process with stored session ID kill stale session, relaunch with --resume
7
+ * fresh — dead or missing process without stored session ID → kill stale session, launch fresh
7
8
  *
8
9
  * SPDX-License-Identifier: MIT
9
10
  */
10
11
 
12
+ import { readdirSync, readFileSync, accessSync, constants } from 'fs'
13
+ import { join } from 'path'
14
+ import { homedir } from 'os'
11
15
  import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
12
16
  import { type SessionsMap } from './sessions.ts'
13
- import { type RoutingConfig } from './config.ts'
17
+ import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
14
18
 
15
19
  // ---------------------------------------------------------------------------
16
20
  // Types
@@ -19,14 +23,63 @@ import { type RoutingConfig } from './config.ts'
19
23
  export interface LaunchOptions {
20
24
  /** Maximum time in ms to poll for the safety prompt. Default: 60000. */
21
25
  pollTimeout?: number
26
+ /** Claude session UUID to resume. When provided, --resume <id> is appended to the CLI command. */
27
+ sessionId?: string
22
28
  }
23
29
 
24
30
  export interface SessionStateResult {
25
31
  channelId: string
26
- action: 'relaunched' | 'launched' | 'failed'
32
+ action: 'reconnected' | 'launched' | 'resumed' | 'failed'
27
33
  sessionName: string
28
34
  }
29
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // Session ID capture helper
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Polls ~/.claude/sessions/ for a new session file matching the given CWD
42
+ * with a startedAt timestamp after `launchTimestamp`. Returns the sessionId
43
+ * string if found, or undefined if capture fails or times out.
44
+ *
45
+ * Polls up to ~2 seconds (4 × 500ms intervals).
46
+ */
47
+ async function captureSessionId(cwd: string, launchTimestamp: number): Promise<string | undefined> {
48
+ const sessionsDir = join(homedir(), '.claude', 'sessions')
49
+ const POLL_INTERVAL_MS = 500
50
+ const POLL_ATTEMPTS = 4
51
+
52
+ for (let i = 0; i < POLL_ATTEMPTS; i++) {
53
+ await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
54
+ try {
55
+ const files = readdirSync(sessionsDir)
56
+ for (const file of files) {
57
+ if (!file.endsWith('.json')) continue
58
+ try {
59
+ const raw = readFileSync(join(sessionsDir, file), 'utf-8')
60
+ const entry = JSON.parse(raw)
61
+ if (
62
+ typeof entry === 'object' &&
63
+ entry !== null &&
64
+ entry.cwd === cwd &&
65
+ typeof entry.startedAt === 'number' &&
66
+ entry.startedAt > launchTimestamp &&
67
+ typeof entry.sessionId === 'string' &&
68
+ entry.sessionId.length > 0
69
+ ) {
70
+ return entry.sessionId as string
71
+ }
72
+ } catch {
73
+ // skip unreadable or malformed files
74
+ }
75
+ }
76
+ } catch {
77
+ // sessionsDir may not exist yet; keep polling
78
+ }
79
+ }
80
+ return undefined
81
+ }
82
+
30
83
  // ---------------------------------------------------------------------------
31
84
  // launchSession
32
85
  // ---------------------------------------------------------------------------
@@ -36,6 +89,10 @@ export interface SessionStateResult {
36
89
  * correct MCP config, polls for the safety prompt, and records the session
37
90
  * in sessions.json on success.
38
91
  *
92
+ * When options.sessionId is provided, appends --resume <id> to the CLI
93
+ * command. If the resume attempt fails, kills the tmux session and retries
94
+ * once with a fresh launch (no --resume).
95
+ *
39
96
  * Returns true on success, false on failure.
40
97
  */
41
98
  export async function launchSession(
@@ -49,64 +106,124 @@ export async function launchSession(
49
106
  ): Promise<boolean> {
50
107
  const name = sessionName(cwd)
51
108
  const pollTimeout = options?.pollTimeout ?? 60_000
109
+ const resumeSessionId = options?.sessionId
52
110
 
53
- // Create detached tmux session with the channel's CWD
54
- await tmuxClient.newSession(name, cwd)
55
- console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
56
-
57
- // Send the claude launch command, then Enter to execute it
58
111
  const escapedConfigPath = routingConfig.mcp_config_path.replace(/'/g, "'\\''")
59
- const launchCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:slack-channel-router`
60
- await tmuxClient.sendKeys(name, launchCmd)
61
- await tmuxClient.sendKeys(name, 'Enter')
62
- console.error(`[slack] Claude launch command sent to session: ${name}`)
112
+ let baseCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
113
+
114
+ if (routingConfig.append_system_prompt_file !== undefined) {
115
+ try {
116
+ accessSync(routingConfig.append_system_prompt_file, constants.R_OK)
117
+ const escapedPromptPath = routingConfig.append_system_prompt_file.replace(/'/g, "'\\''")
118
+ baseCmd += ` --append-system-prompt-file '${escapedPromptPath}'`
119
+ } catch {
120
+ // file missing or unreadable — skip
121
+ }
122
+ }
63
123
 
64
- // Poll capturePane for the safety prompt with exponential backoff.
65
- // Start at 500ms, double each iteration, cap at 5s, total limit 60s.
66
124
  const POLL_START_MS = 500
67
125
  const POLL_CAP_MS = 5_000
68
126
  const PROMPT_TEXT = 'I am using this for local development'
69
127
 
70
- let delay = POLL_START_MS
71
- const deadline = Date.now() + pollTimeout
128
+ // Inner helper: sends the launch command and polls for the safety prompt.
129
+ // Returns { ok: true, capturedId } on success (capturedId may be undefined if capture failed),
130
+ // or { ok: false } when Claude is not running after the poll timeout.
131
+ async function attemptLaunch(
132
+ withResumeId: string | undefined,
133
+ ): Promise<{ ok: true; capturedId: string | undefined } | { ok: false }> {
134
+ const safeResumeId = withResumeId && /^[a-zA-Z0-9_-]+$/.test(withResumeId) ? withResumeId : undefined
135
+ if (withResumeId && !safeResumeId) {
136
+ console.error(`[slack] Invalid session ID format — ignoring resume for channel=${channelId}`)
137
+ }
138
+ const launchTimestamp = Date.now()
139
+ const launchCmd = safeResumeId ? `${baseCmd} --resume ${safeResumeId}` : baseCmd
140
+ if (safeResumeId) {
141
+ console.error(`[slack] Attempting resume launch for channel=${channelId} sessionId=${safeResumeId}`)
142
+ } else {
143
+ console.error(`[slack] Attempting fresh launch for channel=${channelId}`)
144
+ }
145
+ await tmuxClient.sendKeys(name, launchCmd)
146
+ await tmuxClient.sendKeys(name, 'Enter')
147
+ console.error(`[slack] Claude launch command sent to session: ${name}`)
72
148
 
73
- while (Date.now() < deadline) {
74
- await new Promise<void>((resolve) => setTimeout(resolve, delay))
75
- delay = Math.min(delay * 2, POLL_CAP_MS)
149
+ let delay = POLL_START_MS
150
+ const deadline = launchTimestamp + pollTimeout
76
151
 
77
- let pane: string
78
- try {
79
- pane = await tmuxClient.capturePane(name)
80
- } catch {
81
- // capturePane failure is terminal — session may have died
82
- break
152
+ while (Date.now() < deadline) {
153
+ await new Promise<void>((resolve) => setTimeout(resolve, delay))
154
+ delay = Math.min(delay * 2, POLL_CAP_MS)
155
+
156
+ let pane: string
157
+ try {
158
+ pane = await tmuxClient.capturePane(name)
159
+ } catch {
160
+ // capturePane failure is terminal — session may have died
161
+ break
162
+ }
163
+
164
+ if (pane.includes(PROMPT_TEXT)) {
165
+ await tmuxClient.sendKeys(name, 'Enter')
166
+ console.error(`[slack] Safety prompt acknowledged in session: ${name}`)
167
+ const capturedId = await captureSessionId(cwd, launchTimestamp)
168
+ if (capturedId) {
169
+ console.error(`[slack] Session ID captured for channel=${channelId}: ${capturedId}`)
170
+ } else {
171
+ console.error(`[slack] Session ID capture failed for channel=${channelId} — continuing without it`)
172
+ }
173
+ return { ok: true, capturedId }
174
+ }
83
175
  }
84
176
 
85
- if (pane.includes(PROMPT_TEXT)) {
86
- // Safety prompt found acknowledge it and record success
87
- await tmuxClient.sendKeys(name, 'Enter')
88
- console.error(`[slack] Safety prompt acknowledged in session: ${name}`)
89
- const sessions = readSessionsFn()
90
- sessions[channelId] = { tmuxSession: name, lastLaunch: new Date().toISOString() }
91
- writeSessionsFn(sessions)
92
- console.error(`[slack] Session recorded in sessions.json: channel=${channelId}`)
93
- return true
177
+ // Prompt not found — check if Claude is running anyway (forward-compatible)
178
+ const running = await isClaudeRunning(name, tmuxClient)
179
+ if (running) {
180
+ console.error(`[slack] Safety prompt not found but Claude is running — accepting session: ${name}`)
181
+ const capturedId = await captureSessionId(cwd, launchTimestamp)
182
+ if (capturedId) {
183
+ console.error(`[slack] Session ID captured for channel=${channelId}: ${capturedId}`)
184
+ } else {
185
+ console.error(`[slack] Session ID capture failed for channel=${channelId} — continuing without it`)
186
+ }
187
+ return { ok: true, capturedId }
94
188
  }
189
+
190
+ return { ok: false }
95
191
  }
96
192
 
97
- // Prompt not found check if Claude is running anyway (forward-compatible)
98
- const running = await isClaudeRunning(name, tmuxClient)
99
- if (running) {
100
- console.error(`[slack] Safety prompt not found but Claude is running — accepting session: ${name}`)
101
- const sessions = readSessionsFn()
102
- sessions[channelId] = { tmuxSession: name, lastLaunch: new Date().toISOString() }
103
- writeSessionsFn(sessions)
104
- console.error(`[slack] Session recorded in sessions.json: channel=${channelId}`)
105
- return true
193
+ // Create detached tmux session with the channel's CWD
194
+ await tmuxClient.newSession(name, cwd)
195
+ console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
196
+
197
+ // Attempt launch (with --resume if sessionId provided)
198
+ let result = await attemptLaunch(resumeSessionId)
199
+
200
+ // resumeSessionId was provided but launch failed — fall back to a fresh launch
201
+ if (!result.ok && resumeSessionId !== undefined) {
202
+ console.error(`[slack] Resume failed for channel=${channelId} — killing session and retrying with fresh launch`)
203
+ try {
204
+ await tmuxClient.killSession(name)
205
+ } catch {
206
+ // ignore kill errors; proceed with fresh session creation
207
+ }
208
+ await tmuxClient.newSession(name, cwd)
209
+ console.error(`[slack] Session recreated for fresh fallback: ${name} (cwd="${cwd}")`)
210
+ result = await attemptLaunch(undefined)
211
+ }
212
+
213
+ if (!result.ok) {
214
+ console.error(`[slack] Session launch failed — Claude not running in session: ${name}`)
215
+ return false
106
216
  }
107
217
 
108
- console.error(`[slack] Session launch failed — Claude not running in session: ${name}`)
109
- return false
218
+ const sessions = readSessionsFn()
219
+ sessions[channelId] = {
220
+ tmuxSession: name,
221
+ lastLaunch: new Date().toISOString(),
222
+ ...(result.capturedId !== undefined ? { sessionId: result.capturedId } : {}),
223
+ }
224
+ writeSessionsFn(sessions)
225
+ console.error(`[slack] Session recorded in sessions.json: channel=${channelId}`)
226
+ return true
110
227
  }
111
228
 
112
229
  // ---------------------------------------------------------------------------
@@ -114,9 +231,11 @@ export async function launchSession(
114
231
  // ---------------------------------------------------------------------------
115
232
 
116
233
  /**
117
- * On server startup, inspects all configured routes and takes action:
118
- * - Exists (session found): kills session and relaunches
119
- * - Missing: launches fresh
234
+ * On server startup, inspects all configured routes and takes action using a
235
+ * three-branch decision tree per route:
236
+ * - Reconnect: tmux session exists AND Claude is running → send /mcp reconnect, do not relaunch
237
+ * - Resume: dead or missing process with stored session ID → kill stale session, relaunch with --resume
238
+ * - Fresh: dead or missing process without stored session ID → kill stale session, launch fresh
120
239
  *
121
240
  * Returns early with a warning if tmux is unavailable.
122
241
  */
@@ -138,6 +257,9 @@ export async function startupSessionManager(
138
257
 
139
258
  const results: SessionStateResult[] = []
140
259
 
260
+ // Load stored session IDs for all channels once before the route iteration loop
261
+ const storedSessions = readSessionsFn()
262
+
141
263
  for (const [channelId, route] of Object.entries(routingConfig.routes)) {
142
264
  const name = sessionName(route.cwd)
143
265
 
@@ -145,17 +267,38 @@ export async function startupSessionManager(
145
267
  const exists = await tmuxClient.hasSession(name)
146
268
 
147
269
  if (exists) {
148
- // Session exists kill and relaunch regardless of whether Claude is running
149
- console.error(`[slack] Session exists — killing and relaunching: channel=${channelId} session=${name}`)
270
+ const running = await isClaudeRunning(name, tmuxClient)
271
+
272
+ if (running) {
273
+ // Branch 1: Reconnect — session live, send /mcp reconnect <server-name>
274
+ console.error(`[slack] Session live — reconnecting MCP server "${MCP_SERVER_NAME}": channel=${channelId} session=${name}`)
275
+ await tmuxClient.sendKeys(name, `/mcp reconnect ${MCP_SERVER_NAME}`)
276
+ await tmuxClient.sendKeys(name, 'Enter')
277
+ results.push({ channelId, action: 'reconnected', sessionName: name })
278
+ continue
279
+ }
280
+ }
281
+
282
+ // Branch 2 or 3: Dead or missing process — check for stored session ID
283
+ const storedSessionId = storedSessions[channelId]?.sessionId
284
+
285
+ if (exists) {
286
+ // Kill stale tmux session before relaunching
287
+ console.error(`[slack] Stale session found — killing before relaunch: channel=${channelId} session=${name}`)
150
288
  await tmuxClient.killSession(name)
289
+ }
290
+
291
+ if (storedSessionId) {
292
+ // Branch 2: Resume — launch with stored session ID
293
+ console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
151
294
  const ok = await launchSession(
152
295
  channelId, route.cwd, routingConfig, tmuxClient,
153
- readSessionsFn, writeSessionsFn, options,
296
+ readSessionsFn, writeSessionsFn, { ...options, sessionId: storedSessionId },
154
297
  )
155
- results.push({ channelId, action: ok ? 'relaunched' : 'failed', sessionName: name })
298
+ results.push({ channelId, action: ok ? 'resumed' : 'failed', sessionName: name })
156
299
  } else {
157
- // No session — launch fresh
158
- console.error(`[slack] No session found — launching: channel=${channelId} session=${name}`)
300
+ // Branch 3: Fresh — launch without session ID
301
+ console.error(`[slack] No stored session ID — launching fresh: channel=${channelId} session=${name}`)
159
302
  const ok = await launchSession(
160
303
  channelId, route.cwd, routingConfig, tmuxClient,
161
304
  readSessionsFn, writeSessionsFn, options,
package/src/sessions.ts CHANGED
@@ -23,6 +23,7 @@ import { expandTilde } from './config.ts'
23
23
  export interface SessionRecord {
24
24
  tmuxSession: string
25
25
  lastLaunch: string // ISO-8601, e.g. new Date().toISOString()
26
+ sessionId?: string // Claude session UUID, captured after launch
26
27
  }
27
28
 
28
29
  export type SessionsMap = Record<string, SessionRecord>