claude-slack-channel-bots 0.0.3 → 0.1.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 -1
- package/package.json +1 -1
- package/skills/setup-slack-channel-bots/SKILL.md +5 -2
- package/src/config.ts +6 -0
- package/src/postinstall.ts +2 -1
- package/src/registry.ts +2 -2
- package/src/restart.ts +3 -3
- package/src/server.ts +11 -7
- package/src/session-manager.ts +189 -56
- package/src/sessions.ts +1 -0
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ Checks prerequisites, then daemonizes the server.
|
|
|
185
185
|
3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
|
|
186
186
|
4. `routing.json` exists at `STATE_DIR/routing.json` — fails with the full path if not found.
|
|
187
187
|
|
|
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`.
|
|
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`. Conversation context is preserved across server restarts when possible.
|
|
189
189
|
|
|
190
190
|
```
|
|
191
191
|
[slack] Server starting in background (PID 12345)
|
package/package.json
CHANGED
|
@@ -331,8 +331,11 @@ Look for an entry inside `hooks.PreToolUse` with:
|
|
|
331
331
|
{ "matcher": "AskUserQuestion", "hooks": [{ "type": "command", "command": "~/.claude/hooks/ask-relay.sh" }] }
|
|
332
332
|
```
|
|
333
333
|
|
|
334
|
-
**If either entry is missing
|
|
335
|
-
the
|
|
334
|
+
**If either entry is missing OR exists but is missing `"timeout": 2000000`**,
|
|
335
|
+
show the user the exact JSON to add or fix. The timeout is critical — without
|
|
336
|
+
it Claude Code uses a short default timeout, kills the hook before the
|
|
337
|
+
long-poll completes, and falls back to TUI approval. This is the complete
|
|
338
|
+
block for both entries:
|
|
336
339
|
|
|
337
340
|
```jsonc
|
|
338
341
|
"PermissionRequest": [
|
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
|
// ---------------------------------------------------------------------------
|
package/src/postinstall.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
@@ -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'
|
|
@@ -296,7 +296,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
|
|
|
296
296
|
: undefined
|
|
297
297
|
if (channelId) {
|
|
298
298
|
console.error(`[slack] Session disconnected: channel=${channelId} cwd="${cwd}"`)
|
|
299
|
-
scheduleRestart(channelId, cwd)
|
|
299
|
+
scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
|
|
300
300
|
} else {
|
|
301
301
|
console.error(`[slack] Session disconnected: cwd="${cwd}"`)
|
|
302
302
|
}
|
|
@@ -1266,7 +1266,7 @@ export async function main(): Promise<void> {
|
|
|
1266
1266
|
: undefined
|
|
1267
1267
|
if (channelId) {
|
|
1268
1268
|
console.error(`[slack] Session disconnected (SSE abort): channel=${channelId} cwd="${cwd}"`)
|
|
1269
|
-
scheduleRestart(channelId, cwd)
|
|
1269
|
+
scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
|
|
1270
1270
|
} else {
|
|
1271
1271
|
console.error(`[slack] Session disconnected (SSE abort): cwd="${cwd}"`)
|
|
1272
1272
|
}
|
|
@@ -1288,10 +1288,10 @@ export async function main(): Promise<void> {
|
|
|
1288
1288
|
console.error(`[slack] MCP server listening on http://${mcpHost}:${mcpPort}/mcp`)
|
|
1289
1289
|
console.error('')
|
|
1290
1290
|
console.error('Save this to ~/.claude/slack-mcp.json:')
|
|
1291
|
-
console.error(JSON.stringify({ mcpServers: {
|
|
1291
|
+
console.error(JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { type: 'http', url: `http://${mcpHost}:${mcpPort}/mcp` } } }, null, 2))
|
|
1292
1292
|
console.error('')
|
|
1293
1293
|
console.error('Then launch Claude from a project directory with:')
|
|
1294
|
-
console.error(
|
|
1294
|
+
console.error(` claude --mcp-config ~/.claude/slack-mcp.json --dangerously-load-development-channels server:${MCP_SERVER_NAME}`)
|
|
1295
1295
|
console.error('')
|
|
1296
1296
|
|
|
1297
1297
|
// Initialize restart module with adapters bridging tmux + session-manager
|
|
@@ -1311,9 +1311,13 @@ export async function main(): Promise<void> {
|
|
|
1311
1311
|
const exists = await defaultTmuxClient.hasSession(name)
|
|
1312
1312
|
if (exists) await defaultTmuxClient.killSession(name)
|
|
1313
1313
|
},
|
|
1314
|
-
launchSession: (channelId, cwd) => {
|
|
1314
|
+
launchSession: (channelId, cwd, sessionId) => {
|
|
1315
1315
|
if (!routingConfig) return Promise.resolve(false)
|
|
1316
|
-
|
|
1316
|
+
const resolvedSessionId = sessionId ?? readSessions()[channelId]?.sessionId
|
|
1317
|
+
return launchSession(
|
|
1318
|
+
channelId, cwd, routingConfig, defaultTmuxClient, readSessions, writeSessions,
|
|
1319
|
+
resolvedSessionId !== undefined ? { sessionId: resolvedSessionId } : undefined,
|
|
1320
|
+
)
|
|
1317
1321
|
},
|
|
1318
1322
|
getRestartDelay: () => routingConfig?.session_restart_delay ?? 60,
|
|
1319
1323
|
isShuttingDown: () => shuttingDown,
|
package/src/session-manager.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* session-manager.ts — Startup orchestration for tmux-managed Claude Code sessions.
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
5
|
-
*
|
|
6
|
-
* missing
|
|
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 } 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: '
|
|
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,114 @@ 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
|
|
60
|
-
await tmuxClient.sendKeys(name, launchCmd)
|
|
61
|
-
await tmuxClient.sendKeys(name, 'Enter')
|
|
62
|
-
console.error(`[slack] Claude launch command sent to session: ${name}`)
|
|
112
|
+
const baseCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
|
|
63
113
|
|
|
64
|
-
// Poll capturePane for the safety prompt with exponential backoff.
|
|
65
|
-
// Start at 500ms, double each iteration, cap at 5s, total limit 60s.
|
|
66
114
|
const POLL_START_MS = 500
|
|
67
115
|
const POLL_CAP_MS = 5_000
|
|
68
116
|
const PROMPT_TEXT = 'I am using this for local development'
|
|
69
117
|
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
// Inner helper: sends the launch command and polls for the safety prompt.
|
|
119
|
+
// Returns { ok: true, capturedId } on success (capturedId may be undefined if capture failed),
|
|
120
|
+
// or { ok: false } when Claude is not running after the poll timeout.
|
|
121
|
+
async function attemptLaunch(
|
|
122
|
+
withResumeId: string | undefined,
|
|
123
|
+
): Promise<{ ok: true; capturedId: string | undefined } | { ok: false }> {
|
|
124
|
+
const safeResumeId = withResumeId && /^[a-zA-Z0-9_-]+$/.test(withResumeId) ? withResumeId : undefined
|
|
125
|
+
if (withResumeId && !safeResumeId) {
|
|
126
|
+
console.error(`[slack] Invalid session ID format — ignoring resume for channel=${channelId}`)
|
|
127
|
+
}
|
|
128
|
+
const launchTimestamp = Date.now()
|
|
129
|
+
const launchCmd = safeResumeId ? `${baseCmd} --resume ${safeResumeId}` : baseCmd
|
|
130
|
+
if (safeResumeId) {
|
|
131
|
+
console.error(`[slack] Attempting resume launch for channel=${channelId} sessionId=${safeResumeId}`)
|
|
132
|
+
} else {
|
|
133
|
+
console.error(`[slack] Attempting fresh launch for channel=${channelId}`)
|
|
134
|
+
}
|
|
135
|
+
await tmuxClient.sendKeys(name, launchCmd)
|
|
136
|
+
await tmuxClient.sendKeys(name, 'Enter')
|
|
137
|
+
console.error(`[slack] Claude launch command sent to session: ${name}`)
|
|
72
138
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
delay = Math.min(delay * 2, POLL_CAP_MS)
|
|
139
|
+
let delay = POLL_START_MS
|
|
140
|
+
const deadline = launchTimestamp + pollTimeout
|
|
76
141
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
while (Date.now() < deadline) {
|
|
143
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay))
|
|
144
|
+
delay = Math.min(delay * 2, POLL_CAP_MS)
|
|
145
|
+
|
|
146
|
+
let pane: string
|
|
147
|
+
try {
|
|
148
|
+
pane = await tmuxClient.capturePane(name)
|
|
149
|
+
} catch {
|
|
150
|
+
// capturePane failure is terminal — session may have died
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (pane.includes(PROMPT_TEXT)) {
|
|
155
|
+
await tmuxClient.sendKeys(name, 'Enter')
|
|
156
|
+
console.error(`[slack] Safety prompt acknowledged in session: ${name}`)
|
|
157
|
+
const capturedId = await captureSessionId(cwd, launchTimestamp)
|
|
158
|
+
if (capturedId) {
|
|
159
|
+
console.error(`[slack] Session ID captured for channel=${channelId}: ${capturedId}`)
|
|
160
|
+
} else {
|
|
161
|
+
console.error(`[slack] Session ID capture failed for channel=${channelId} — continuing without it`)
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, capturedId }
|
|
164
|
+
}
|
|
83
165
|
}
|
|
84
166
|
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.error(`[slack] Safety prompt
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
167
|
+
// Prompt not found — check if Claude is running anyway (forward-compatible)
|
|
168
|
+
const running = await isClaudeRunning(name, tmuxClient)
|
|
169
|
+
if (running) {
|
|
170
|
+
console.error(`[slack] Safety prompt not found but Claude is running — accepting session: ${name}`)
|
|
171
|
+
const capturedId = await captureSessionId(cwd, launchTimestamp)
|
|
172
|
+
if (capturedId) {
|
|
173
|
+
console.error(`[slack] Session ID captured for channel=${channelId}: ${capturedId}`)
|
|
174
|
+
} else {
|
|
175
|
+
console.error(`[slack] Session ID capture failed for channel=${channelId} — continuing without it`)
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, capturedId }
|
|
94
178
|
}
|
|
179
|
+
|
|
180
|
+
return { ok: false }
|
|
95
181
|
}
|
|
96
182
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
183
|
+
// Create detached tmux session with the channel's CWD
|
|
184
|
+
await tmuxClient.newSession(name, cwd)
|
|
185
|
+
console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
|
|
186
|
+
|
|
187
|
+
// Attempt launch (with --resume if sessionId provided)
|
|
188
|
+
let result = await attemptLaunch(resumeSessionId)
|
|
189
|
+
|
|
190
|
+
// resumeSessionId was provided but launch failed — fall back to a fresh launch
|
|
191
|
+
if (!result.ok && resumeSessionId !== undefined) {
|
|
192
|
+
console.error(`[slack] Resume failed for channel=${channelId} — killing session and retrying with fresh launch`)
|
|
193
|
+
try {
|
|
194
|
+
await tmuxClient.killSession(name)
|
|
195
|
+
} catch {
|
|
196
|
+
// ignore kill errors; proceed with fresh session creation
|
|
197
|
+
}
|
|
198
|
+
await tmuxClient.newSession(name, cwd)
|
|
199
|
+
console.error(`[slack] Session recreated for fresh fallback: ${name} (cwd="${cwd}")`)
|
|
200
|
+
result = await attemptLaunch(undefined)
|
|
106
201
|
}
|
|
107
202
|
|
|
108
|
-
|
|
109
|
-
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
console.error(`[slack] Session launch failed — Claude not running in session: ${name}`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sessions = readSessionsFn()
|
|
209
|
+
sessions[channelId] = {
|
|
210
|
+
tmuxSession: name,
|
|
211
|
+
lastLaunch: new Date().toISOString(),
|
|
212
|
+
...(result.capturedId !== undefined ? { sessionId: result.capturedId } : {}),
|
|
213
|
+
}
|
|
214
|
+
writeSessionsFn(sessions)
|
|
215
|
+
console.error(`[slack] Session recorded in sessions.json: channel=${channelId}`)
|
|
216
|
+
return true
|
|
110
217
|
}
|
|
111
218
|
|
|
112
219
|
// ---------------------------------------------------------------------------
|
|
@@ -114,9 +221,11 @@ export async function launchSession(
|
|
|
114
221
|
// ---------------------------------------------------------------------------
|
|
115
222
|
|
|
116
223
|
/**
|
|
117
|
-
* On server startup, inspects all configured routes and takes action
|
|
118
|
-
*
|
|
119
|
-
* -
|
|
224
|
+
* On server startup, inspects all configured routes and takes action using a
|
|
225
|
+
* three-branch decision tree per route:
|
|
226
|
+
* - Reconnect: tmux session exists AND Claude is running → send /mcp reconnect, do not relaunch
|
|
227
|
+
* - Resume: dead or missing process with stored session ID → kill stale session, relaunch with --resume
|
|
228
|
+
* - Fresh: dead or missing process without stored session ID → kill stale session, launch fresh
|
|
120
229
|
*
|
|
121
230
|
* Returns early with a warning if tmux is unavailable.
|
|
122
231
|
*/
|
|
@@ -138,6 +247,9 @@ export async function startupSessionManager(
|
|
|
138
247
|
|
|
139
248
|
const results: SessionStateResult[] = []
|
|
140
249
|
|
|
250
|
+
// Load stored session IDs for all channels once before the route iteration loop
|
|
251
|
+
const storedSessions = readSessionsFn()
|
|
252
|
+
|
|
141
253
|
for (const [channelId, route] of Object.entries(routingConfig.routes)) {
|
|
142
254
|
const name = sessionName(route.cwd)
|
|
143
255
|
|
|
@@ -145,17 +257,38 @@ export async function startupSessionManager(
|
|
|
145
257
|
const exists = await tmuxClient.hasSession(name)
|
|
146
258
|
|
|
147
259
|
if (exists) {
|
|
148
|
-
|
|
149
|
-
|
|
260
|
+
const running = await isClaudeRunning(name, tmuxClient)
|
|
261
|
+
|
|
262
|
+
if (running) {
|
|
263
|
+
// Branch 1: Reconnect — session live, send /mcp reconnect <server-name>
|
|
264
|
+
console.error(`[slack] Session live — reconnecting MCP server "${MCP_SERVER_NAME}": channel=${channelId} session=${name}`)
|
|
265
|
+
await tmuxClient.sendKeys(name, `/mcp reconnect ${MCP_SERVER_NAME}`)
|
|
266
|
+
await tmuxClient.sendKeys(name, 'Enter')
|
|
267
|
+
results.push({ channelId, action: 'reconnected', sessionName: name })
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Branch 2 or 3: Dead or missing process — check for stored session ID
|
|
273
|
+
const storedSessionId = storedSessions[channelId]?.sessionId
|
|
274
|
+
|
|
275
|
+
if (exists) {
|
|
276
|
+
// Kill stale tmux session before relaunching
|
|
277
|
+
console.error(`[slack] Stale session found — killing before relaunch: channel=${channelId} session=${name}`)
|
|
150
278
|
await tmuxClient.killSession(name)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (storedSessionId) {
|
|
282
|
+
// Branch 2: Resume — launch with stored session ID
|
|
283
|
+
console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
|
|
151
284
|
const ok = await launchSession(
|
|
152
285
|
channelId, route.cwd, routingConfig, tmuxClient,
|
|
153
|
-
readSessionsFn, writeSessionsFn, options,
|
|
286
|
+
readSessionsFn, writeSessionsFn, { ...options, sessionId: storedSessionId },
|
|
154
287
|
)
|
|
155
|
-
results.push({ channelId, action: ok ? '
|
|
288
|
+
results.push({ channelId, action: ok ? 'resumed' : 'failed', sessionName: name })
|
|
156
289
|
} else {
|
|
157
|
-
//
|
|
158
|
-
console.error(`[slack] No session
|
|
290
|
+
// Branch 3: Fresh — launch without session ID
|
|
291
|
+
console.error(`[slack] No stored session ID — launching fresh: channel=${channelId} session=${name}`)
|
|
159
292
|
const ok = await launchSession(
|
|
160
293
|
channelId, route.cwd, routingConfig, tmuxClient,
|
|
161
294
|
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>
|