claude-slack-channel-bots 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,7 @@ See the sections below for manual configuration details if you prefer not to use
39
39
  - [Bun](https://bun.sh) v1.0+
40
40
  - [tmux](https://github.com/tmux/tmux) (required for server-managed sessions)
41
41
  - [Claude Code](https://claude.ai/code) installed and authenticated
42
+ - `ss` from [iproute2](https://github.com/iproute2/iproute2) on your `PATH` (required for session ID discovery; pre-installed on most Linux distributions)
42
43
  - `curl` and `jq` on your `PATH` (required for the permission relay hooks)
43
44
  - Slack workspace admin access (to create and configure the Slack app)
44
45
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.2.1",
4
- "description": "Multi-session Slack-to-Claude bridge run multiple Claude Code bots across Slack channels via Socket Mode",
3
+ "version": "0.3.0",
4
+ "description": "Multi-session Slack-to-Claude bridge \u2014 run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-slack-channel-bots": "src/cli.ts"
@@ -0,0 +1,83 @@
1
+ import { homedir } from 'os'
2
+ import { join } from 'path'
3
+ import { readFile } from 'fs/promises'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // getPeerPidByPort
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Discover the PID of a process that has an established TCP connection
11
+ * from serverPort to peerPort on the loopback interface (IPv4 or IPv6).
12
+ *
13
+ * Runs: ss -tnp
14
+ * Filters for lines where local addr is 127.0.0.1:<serverPort> or ::1:<serverPort>
15
+ * and peer addr is 127.0.0.1:<peerPort> or ::1:<peerPort>.
16
+ *
17
+ * Returns the PID as a number, or null if not found or on any error.
18
+ */
19
+ export async function getPeerPidByPort(
20
+ peerPort: number,
21
+ serverPort: number,
22
+ ): Promise<number | null> {
23
+ try {
24
+ const proc = Bun.spawn(['ss', '-tnp'], { stdout: 'pipe', stderr: 'pipe' })
25
+ const text = await new Response(proc.stdout).text()
26
+
27
+ for (const line of text.split('\n')) {
28
+ if (!matchesPorts(line, peerPort, serverPort)) continue
29
+ const pid = parsePid(line)
30
+ if (pid !== null) return pid
31
+ }
32
+ return null
33
+ } catch (err) {
34
+ console.error('[slack] peer-pid: getPeerPidByPort failed', err)
35
+ return null
36
+ }
37
+ }
38
+
39
+ /** Returns true if the ss output line matches the expected port pair on loopback. */
40
+ function matchesPorts(line: string, peerPort: number, serverPort: number): boolean {
41
+ // ss -tnp columns: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port ...
42
+ // Accept both 127.0.0.1 and ::1 loopback addresses.
43
+ const loopback = '(?:127\\.0\\.0\\.1|\\[?::1\\]?)'
44
+ const local = new RegExp(`${loopback}:${serverPort}(?:\\s|$)`)
45
+ const peer = new RegExp(`${loopback}:${peerPort}(?:\\s|$)`)
46
+ return local.test(line) && peer.test(line)
47
+ }
48
+
49
+ /**
50
+ * Parse PID from ss users field, e.g.:
51
+ * users:(("bun",pid=12345,fd=7))
52
+ * Returns the PID as a number, or null if unparseable.
53
+ */
54
+ function parsePid(line: string): number | null {
55
+ const m = line.match(/pid=(\d+)/)
56
+ if (!m) return null
57
+ const pid = parseInt(m[1], 10)
58
+ return isNaN(pid) ? null : pid
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // getSessionIdForPid
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Read the Claude Code session ID stored at ~/.claude/sessions/<pid>.json.
67
+ * Returns the sessionId string, or null if the file is missing, unreadable,
68
+ * or does not contain a valid sessionId string field.
69
+ *
70
+ * Never throws.
71
+ */
72
+ export async function getSessionIdForPid(pid: number): Promise<string | null> {
73
+ try {
74
+ const filePath = join(homedir(), '.claude', 'sessions', `${pid}.json`)
75
+ const raw = await readFile(filePath, 'utf-8')
76
+ const parsed = JSON.parse(raw)
77
+ if (parsed && typeof parsed.sessionId === 'string') return parsed.sessionId
78
+ return null
79
+ } catch (err) {
80
+ console.error('[slack] peer-pid: getSessionIdForPid failed', err)
81
+ return null
82
+ }
83
+ }
package/src/registry.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js'
17
17
  import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
18
+ import { getPeerPidByPort, getSessionIdForPid } from './peer-pid.ts'
19
+ import { readSessions, writeSessions, type SessionsMap } from './sessions.ts'
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Types
@@ -37,6 +39,12 @@ export interface SessionEntry {
37
39
  deliveredChannels: Set<string>
38
40
  /** Whether the session is currently connected (transport alive) */
39
41
  connected: boolean
42
+ /**
43
+ * TCP peer port of the most recent MCP request from this session.
44
+ * Updated per-request in server.ts before transport.handleRequest().
45
+ * Used by the CallToolRequestSchema handler to identify the calling process.
46
+ */
47
+ peerPort: number
40
48
  }
41
49
 
42
50
  /**
@@ -138,6 +146,7 @@ export function registerSession(
138
146
  server: resolvedServer,
139
147
  deliveredChannels,
140
148
  connected: true,
149
+ peerPort: 0,
141
150
  }
142
151
  registry.set(cwd, entry)
143
152
  return entry
@@ -323,6 +332,16 @@ export interface SessionToolDeps {
323
332
  resolveUserName: (userId: string) => Promise<string>
324
333
  /** Consume a pending ack entry — returns true if it existed */
325
334
  consumeAck: (channelId: string, messageTs: string) => boolean
335
+ /** TCP port the MCP HTTP server is listening on — used for peer PID discovery */
336
+ serverPort: number
337
+ /** Injectable for testing: replaces getPeerPidByPort in the discovery hook */
338
+ getPeerPidByPort?: (peerPort: number, serverPort: number) => Promise<number | null>
339
+ /** Injectable for testing: replaces getSessionIdForPid in the discovery hook */
340
+ getSessionIdForPid?: (pid: number) => Promise<string | null>
341
+ /** Injectable for testing: replaces readSessions in the discovery hook */
342
+ readSessions?: () => SessionsMap
343
+ /** Injectable for testing: replaces writeSessions in the discovery hook */
344
+ writeSessions?: (sessions: SessionsMap) => void
326
345
  }
327
346
 
328
347
  const MCP_INSTRUCTIONS = [
@@ -351,6 +370,10 @@ export function createSessionServer(
351
370
  deps: SessionToolDeps,
352
371
  ): Server {
353
372
  const { web, assertOutboundAllowed, assertSendable, getAccess, resolveUserName, inboxDir, consumeAck } = deps
373
+ const _getPeerPid = deps.getPeerPidByPort ?? getPeerPidByPort
374
+ const _getSessionId = deps.getSessionIdForPid ?? getSessionIdForPid
375
+ const _readSessions = deps.readSessions ?? readSessions
376
+ const _writeSessions = deps.writeSessions ?? writeSessions
354
377
 
355
378
  const server = new Server(
356
379
  { name: MCP_SERVER_NAME, version: '0.1.0' },
@@ -478,7 +501,8 @@ export function createSessionServer(
478
501
 
479
502
  const DEFAULT_CHUNK_LIMIT = 4000
480
503
 
481
- switch (name) {
504
+ // Wrap switch in IIFE so we can trigger post-call discovery after the result is computed
505
+ const result = await (async () => { switch (name) {
482
506
  // ---------------------------------------------------------------------
483
507
  // reply
484
508
  // ---------------------------------------------------------------------
@@ -672,7 +696,36 @@ export function createSessionServer(
672
696
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
673
697
  isError: true,
674
698
  }
699
+ } })() // end IIFE
700
+
701
+ // -------------------------------------------------------------------------
702
+ // Post-call: fire-and-forget peer PID → session ID discovery (t2.nrd.so.a7)
703
+ // Never blocks or delays the tool call response.
704
+ // -------------------------------------------------------------------------
705
+ if (entry.peerPort > 0) {
706
+ const peerPort = entry.peerPort
707
+ const serverPort = deps.serverPort
708
+ const channelId = entry.channelId
709
+ ;(async () => {
710
+ try {
711
+ const pid = await _getPeerPid(peerPort, serverPort)
712
+ if (pid === null) return
713
+ const sessionId = await _getSessionId(pid)
714
+ if (!sessionId) return
715
+ const sessions = _readSessions()
716
+ const record = sessions[channelId]
717
+ if (!record) return
718
+ if (record.sessionId === sessionId) return
719
+ sessions[channelId] = { ...record, sessionId }
720
+ _writeSessions(sessions)
721
+ console.error(`[slack] peer-pid: updated sessionId for channel=${channelId} pid=${pid}`)
722
+ } catch (err) {
723
+ console.error('[slack] peer-pid: session discovery failed', err)
724
+ }
725
+ })()
675
726
  }
727
+
728
+ return result
676
729
  })
677
730
 
678
731
  return server
package/src/server.ts CHANGED
@@ -106,6 +106,43 @@ const STATE_DIR = process.env['SLACK_STATE_DIR'] || join(homedir(), '.claude', '
106
106
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
107
107
  const INBOX_DIR = join(STATE_DIR, 'inbox')
108
108
  const PID_FILE = join(STATE_DIR, 'server.pid')
109
+ const KEEP_ALIVE_INTERVAL_MS = 30_000
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // SSE keep-alive — prevents idle stream disconnection
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const keepAliveTimers = new Map<WebStandardStreamableHTTPServerTransport, ReturnType<typeof setInterval>>()
116
+
117
+ export function startSseKeepAlive(transport: WebStandardStreamableHTTPServerTransport): void {
118
+ const id = setInterval(() => {
119
+ const streamEntry = (transport as any)._streamMapping?.get('_GET_stream')
120
+ if (streamEntry?.controller && streamEntry?.encoder) {
121
+ try {
122
+ streamEntry.controller.enqueue(streamEntry.encoder.encode(':ping\n\n'))
123
+ } catch {
124
+ clearInterval(id)
125
+ keepAliveTimers.delete(transport)
126
+ }
127
+ }
128
+ }, KEEP_ALIVE_INTERVAL_MS)
129
+ keepAliveTimers.set(transport, id)
130
+ }
131
+
132
+ export function stopSseKeepAlive(transport: WebStandardStreamableHTTPServerTransport): void {
133
+ const id = keepAliveTimers.get(transport)
134
+ if (id !== undefined) {
135
+ clearInterval(id)
136
+ keepAliveTimers.delete(transport)
137
+ }
138
+ }
139
+
140
+ export function stopAllKeepAliveTimers(): void {
141
+ for (const id of keepAliveTimers.values()) {
142
+ clearInterval(id)
143
+ }
144
+ keepAliveTimers.clear()
145
+ }
109
146
 
110
147
  // ---------------------------------------------------------------------------
111
148
  // Bootstrap — tokens & state directory
@@ -273,6 +310,7 @@ const sessionToolDeps: SessionToolDeps = {
273
310
  inboxDir: INBOX_DIR,
274
311
  resolveUserName,
275
312
  consumeAck,
313
+ serverPort: 0, // updated to actual port in main() before Bun.serve
276
314
  }
277
315
 
278
316
  // ---------------------------------------------------------------------------
@@ -298,6 +336,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
298
336
  server: null as unknown as import('@modelcontextprotocol/sdk/server/index.js').Server,
299
337
  deliveredChannels,
300
338
  connected: true,
339
+ peerPort: 0,
301
340
  }
302
341
 
303
342
  const transport = new WebStandardStreamableHTTPServerTransport({
@@ -306,6 +345,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
306
345
  // Transport-level init. Roots resolution happens via server.oninitialized.
307
346
  },
308
347
  onsessionclosed: (mcpSessionId) => {
348
+ stopSseKeepAlive(transport)
309
349
  // Session closed — clean up pending or registered state
310
350
  const pending = getPendingSession(mcpSessionId)
311
351
  if (pending) {
@@ -321,7 +361,8 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
321
361
  : undefined
322
362
  if (channelId) {
323
363
  console.error(`[slack] Session disconnected: channel=${channelId} cwd="${cwd}"`)
324
- scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
364
+ const storedId = readSessions()[channelId]?.sessionId
365
+ scheduleRestart(channelId, cwd, storedId !== 'pending' ? storedId : undefined)
325
366
  } else {
326
367
  console.error(`[slack] Session disconnected: cwd="${cwd}"`)
327
368
  }
@@ -330,6 +371,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
330
371
  })
331
372
 
332
373
  entryStub.transport = transport
374
+ startSseKeepAlive(transport)
333
375
 
334
376
  // Build the MCP server (closes over entryStub.deliveredChannels)
335
377
  const server = createSessionServer(entryStub, sessionToolDeps)
@@ -813,6 +855,7 @@ async function shutdown(signal: string): Promise<void> {
813
855
  shuttingDown = true
814
856
  stopHealthCheck()
815
857
  cancelAllRestartTimers()
858
+ stopAllKeepAliveTimers()
816
859
 
817
860
  console.error(`[slack] Received ${signal} — shutting down`)
818
861
 
@@ -915,6 +958,9 @@ export async function main(): Promise<void> {
915
958
  await socket.start()
916
959
  console.error('[slack] Socket Mode connected')
917
960
 
961
+ // Propagate resolved port to tool deps for peer PID discovery
962
+ sessionToolDeps.serverPort = mcpPort
963
+
918
964
  // -------------------------------------------------------------------------
919
965
  // HTTP server — single /mcp endpoint, roots-based session identity
920
966
  // -------------------------------------------------------------------------
@@ -1281,6 +1327,12 @@ export async function main(): Promise<void> {
1281
1327
  }
1282
1328
  // entry is non-null here (null means init request, but we have a session ID)
1283
1329
 
1330
+ // Propagate peer port to registered sessions for tool call PID discovery
1331
+ if (entry !== null && 'channelId' in entry) {
1332
+ const remoteAddr = server.requestIP(req) as { address: string; port: number } | null
1333
+ if (remoteAddr?.port) (entry as SessionEntry).peerPort = remoteAddr.port
1334
+ }
1335
+
1284
1336
  // For GET requests (SSE streams), attach an abort listener to detect
1285
1337
  // client disconnections. The MCP SDK's onsessionclosed only fires on
1286
1338
  // explicit HTTP DELETE, so silent TCP/tmux kills are never detected
@@ -1302,7 +1354,8 @@ export async function main(): Promise<void> {
1302
1354
  : undefined
1303
1355
  if (channelId) {
1304
1356
  console.error(`[slack] Session disconnected (SSE abort): channel=${channelId} cwd="${cwd}"`)
1305
- scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
1357
+ const storedId = readSessions()[channelId]?.sessionId
1358
+ scheduleRestart(channelId, cwd, storedId !== 'pending' ? storedId : undefined)
1306
1359
  } else {
1307
1360
  console.error(`[slack] Session disconnected (SSE abort): cwd="${cwd}"`)
1308
1361
  }
@@ -1365,7 +1418,9 @@ export async function main(): Promise<void> {
1365
1418
  },
1366
1419
  launchSession: async (channelId, cwd, sessionId) => {
1367
1420
  if (!routingConfig) return false
1368
- const resolvedSessionId = sessionId ?? readSessions()[channelId]?.sessionId
1421
+ const stored = sessionId ?? readSessions()[channelId]?.sessionId
1422
+ // Treat "pending" as undefined — fall back to a fresh launch, not --resume
1423
+ const resolvedSessionId = stored !== 'pending' ? stored : undefined
1369
1424
  const record = await launchSession(
1370
1425
  channelId, cwd, routingConfig, defaultTmuxClient,
1371
1426
  resolvedSessionId !== undefined ? { sessionId: resolvedSessionId } : undefined,
@@ -9,12 +9,28 @@
9
9
  * SPDX-License-Identifier: MIT
10
10
  */
11
11
 
12
- import { readFileSync, accessSync, constants } from 'fs'
12
+ import { accessSync, existsSync, constants } from 'fs'
13
13
  import { homedir } from 'node:os'
14
- import { type TmuxClient, sessionName, isClaudeRunning, getClaudePid } from './tmux.ts'
14
+ import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
15
15
  import { type SessionsMap, type SessionRecord } from './sessions.ts'
16
16
  import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
17
17
 
18
+ // ---------------------------------------------------------------------------
19
+ // JSONL existence helper
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Returns true if the JSONL conversation file exists for the given session.
24
+ * The slug is computed from the CWD by replacing all non-alphanumeric-or-hyphen
25
+ * characters with hyphens, matching Claude's project directory naming.
26
+ */
27
+ export function jsonlExistsForSession(cwd: string, sessionId: string): boolean {
28
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) return false
29
+ const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-')
30
+ const path = `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
31
+ return existsSync(path)
32
+ }
33
+
18
34
  // ---------------------------------------------------------------------------
19
35
  // Types
20
36
  // ---------------------------------------------------------------------------
@@ -78,9 +94,10 @@ export async function launchSession(
78
94
  const PROMPT_TEXT = 'I am using this for local development'
79
95
  const NO_CONVERSATION_TEXT = 'No conversation found'
80
96
 
81
- // Inner helper: sends the launch command and polls for the safety prompt,
82
- // then continues polling for Claude PID and session file discovery.
83
- // Returns the discovered SessionRecord on success, or null on failure/timeout.
97
+ // Inner helper: sends the launch command and polls for the safety prompt.
98
+ // Returns a SessionRecord immediately after the safety prompt is acknowledged.
99
+ // Resume path: sessionId = safeResumeId. Fresh path: sessionId = "pending".
100
+ // Returns null on timeout (safety prompt never appeared) or capturePane failure.
84
101
  async function attemptLaunch(
85
102
  withResumeId: string | undefined,
86
103
  sessionName_: string,
@@ -101,7 +118,6 @@ export async function launchSession(
101
118
  console.error(`[slack] Claude launch command sent to session: ${sessionName_}`)
102
119
 
103
120
  let delay = POLL_START_MS
104
- let promptAcknowledged = false
105
121
 
106
122
  while (Date.now() < launchDeadline) {
107
123
  await new Promise<void>((resolve) => setTimeout(resolve, delay))
@@ -115,46 +131,23 @@ export async function launchSession(
115
131
  return null
116
132
  }
117
133
 
118
- if (!promptAcknowledged && pane.includes(PROMPT_TEXT)) {
119
- await tmuxClient.sendKeys(sessionName_, 'Enter')
120
- console.error(`[slack] Safety prompt acknowledged in session: ${sessionName_}`)
121
- promptAcknowledged = true
122
- }
123
-
124
134
  // Fast-fail: "No conversation found" means the resume failed
125
135
  if (pane.includes(NO_CONVERSATION_TEXT)) {
126
136
  console.error(`[slack] "No conversation found" detected — fast-fail resume for channel=${channelId}`)
127
137
  return 'NO_CONVERSATION' as unknown as SessionRecord
128
138
  }
129
139
 
130
- // Try to discover session ID via PID
131
- const pid = await getClaudePid(sessionName_, tmuxClient)
132
- if (pid !== null) {
133
- const sessionFilePath = `${homedir()}/.claude/sessions/${pid}.json`
134
- try {
135
- const raw = readFileSync(sessionFilePath, 'utf-8')
136
- const entry = JSON.parse(raw)
137
- if (
138
- typeof entry === 'object' &&
139
- entry !== null &&
140
- typeof entry.sessionId === 'string' &&
141
- entry.sessionId.length > 0
142
- ) {
143
- const foundId = entry.sessionId as string
144
- console.error(`[slack] launchSession: discovered sessionId=${foundId} via PID=${pid} for channel=${channelId}`)
145
- return {
146
- tmuxSession: sessionName_,
147
- lastLaunch: new Date().toISOString(),
148
- sessionId: foundId,
149
- }
150
- }
151
- } catch {
152
- // Session file not yet written — keep polling
140
+ if (pane.includes(PROMPT_TEXT)) {
141
+ await tmuxClient.sendKeys(sessionName_, 'Enter')
142
+ console.error(`[slack] Safety prompt acknowledged in session: ${sessionName_}`)
143
+ return {
144
+ tmuxSession: sessionName_,
145
+ lastLaunch: new Date().toISOString(),
146
+ sessionId: safeResumeId ?? 'pending',
153
147
  }
154
148
  }
155
149
  }
156
150
 
157
- console.error(`[slack] Timed out waiting for session ID for channel=${channelId}`)
158
151
  return null
159
152
  }
160
153
 
@@ -256,41 +249,22 @@ export async function startupSessionManager(
256
249
 
257
250
  if (running) {
258
251
  // Branch 1: Reconnect — session live, send /mcp reconnect <server-name>
259
- const reconnectSessionId = storedSessions[channelId]?.sessionId ?? 'none'
252
+ const storedId = storedSessions[channelId]?.sessionId
253
+ const reconnectSessionId = storedId ?? 'none'
260
254
  console.error(`[slack] startupSessionManager: branch=reconnect channel=${channelId} sessionId=${reconnectSessionId}`)
261
255
  console.error(`[slack] Session live — reconnecting MCP server "${MCP_SERVER_NAME}": channel=${channelId} session=${name}`)
262
256
  await tmuxClient.sendKeys(name, `/mcp reconnect ${MCP_SERVER_NAME}`, 'Enter')
263
257
 
264
- // Discover session ID via PID-based file lookup
265
- const pid = await getClaudePid(name, tmuxClient)
266
- if (pid !== null) {
267
- const sessionFilePath = `${homedir()}/.claude/sessions/${pid}.json`
268
- try {
269
- const raw = readFileSync(sessionFilePath, 'utf-8')
270
- const entry = JSON.parse(raw)
271
- if (
272
- typeof entry === 'object' &&
273
- entry !== null &&
274
- typeof entry.sessionId === 'string' &&
275
- entry.sessionId.length > 0
276
- ) {
277
- const foundId = entry.sessionId as string
278
- console.error(`[slack] startupSessionManager: reconnect discovered sessionId=${foundId} via PID=${pid} for channel=${channelId} (${Date.now() - routeStart}ms)`)
279
- resultMap.set(channelId, {
280
- tmuxSession: name,
281
- lastLaunch: new Date().toISOString(),
282
- sessionId: foundId,
283
- })
284
- succeeded++
285
- return
286
- }
287
- } catch {
288
- console.error(`[slack] startupSessionManager: reconnect — could not read session file for PID=${pid} channel=${channelId}`)
289
- }
290
- } else {
291
- console.error(`[slack] startupSessionManager: reconnect — no claude PID found for channel=${channelId}`)
292
- }
293
- failed++
258
+ // Use stored session ID; fall back to "pending" if absent or already pending.
259
+ // The tool call hook (Epic 1) will update it to the real ID on the next tool call.
260
+ const sessionId = (storedId && storedId !== 'pending') ? storedId : 'pending'
261
+ console.error(`[slack] startupSessionManager: reconnect using sessionId=${sessionId} for channel=${channelId} (${Date.now() - routeStart}ms)`)
262
+ resultMap.set(channelId, {
263
+ tmuxSession: name,
264
+ lastLaunch: new Date().toISOString(),
265
+ sessionId,
266
+ })
267
+ succeeded++
294
268
  return
295
269
  }
296
270
  }
@@ -309,7 +283,12 @@ export async function startupSessionManager(
309
283
  const effectiveTimeout = Math.min(options?.pollTimeout ?? 120_000, startupTimeout)
310
284
  const launchOpts = { ...options, pollTimeout: effectiveTimeout }
311
285
 
312
- if (storedSessionId && storedSessionId !== 'pending') {
286
+ const shouldResume = !!(storedSessionId && storedSessionId !== 'pending' && jsonlExistsForSession(route.cwd, storedSessionId))
287
+ if (!shouldResume && storedSessionId && storedSessionId !== 'pending') {
288
+ console.error(`[slack] startupSessionManager: no JSONL for stored session — skipping resume: channel=${channelId} sessionId=${storedSessionId}`)
289
+ }
290
+
291
+ if (shouldResume) {
313
292
  // Branch 2: Resume — launch with stored session ID
314
293
  console.error(`[slack] startupSessionManager: branch=resume channel=${channelId} sessionId=${storedSessionId}`)
315
294
  console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
@@ -319,7 +298,7 @@ export async function startupSessionManager(
319
298
  )
320
299
  const elapsed = Date.now() - routeStart
321
300
  if (record !== null) {
322
- console.error(`[slack] startupSessionManager: channel=${channelId} resumed in ${elapsed}ms`)
301
+ console.error(`[slack] startupSessionManager: channel=${channelId} resumed in ${elapsed}ms (storedId=${storedSessionId} discoveredId=${record.sessionId})`)
323
302
  resultMap.set(channelId, record)
324
303
  succeeded++
325
304
  } else {