claude-slack-channel-bots 0.2.0 → 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 +1 -0
- package/package.json +4 -4
- package/src/peer-pid.ts +83 -0
- package/src/registry.ts +54 -1
- package/src/server.ts +58 -3
- package/src/session-manager.ts +48 -69
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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slack-channel-bots",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Multi-session Slack-to-Claude bridge
|
|
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
|
-
"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/peer-pid.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/session-manager.ts
CHANGED
|
@@ -9,12 +9,28 @@
|
|
|
9
9
|
* SPDX-License-Identifier: MIT
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { accessSync, existsSync, constants } from 'fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
|
-
import { type TmuxClient, sessionName, isClaudeRunning
|
|
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
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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 {
|