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 +2 -1
- package/package.json +1 -1
- package/skills/EXAMPLE_CLAUDE.md +15 -0
- package/skills/setup-slack-channel-bots/SKILL.md +47 -4
- package/src/cli.ts +0 -0
- package/src/config.ts +12 -0
- package/src/postinstall.ts +2 -1
- package/src/registry.ts +2 -2
- package/src/restart.ts +3 -3
- package/src/server.ts +40 -15
- package/src/session-manager.ts +199 -56
- package/src/sessions.ts +1 -0
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
|
@@ -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 —
|
|
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
|
|
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
|
|
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
|
|
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)
|
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
|
@@ -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
|
-
//
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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,
|
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, 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: '
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
delay = Math.min(delay * 2, POLL_CAP_MS)
|
|
149
|
+
let delay = POLL_START_MS
|
|
150
|
+
const deadline = launchTimestamp + pollTimeout
|
|
76
151
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.error(`[slack] Safety prompt
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
*
|
|
119
|
-
* -
|
|
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
|
-
|
|
149
|
-
|
|
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 ? '
|
|
298
|
+
results.push({ channelId, action: ok ? 'resumed' : 'failed', sessionName: name })
|
|
156
299
|
} else {
|
|
157
|
-
//
|
|
158
|
-
console.error(`[slack] No session
|
|
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>
|