claude-slack-channel-bots 0.2.1 → 0.4.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 +5 -1
- package/package.json +2 -2
- package/src/config.ts +11 -0
- package/src/cozempic.ts +150 -0
- package/src/peer-pid.ts +83 -0
- package/src/registry.ts +54 -1
- package/src/server.ts +62 -4
- package/src/session-manager.ts +58 -71
package/README.md
CHANGED
|
@@ -39,8 +39,10 @@ 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)
|
|
45
|
+
- **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — enables session file cleaning before `--resume` for faster load times
|
|
44
46
|
|
|
45
47
|
---
|
|
46
48
|
|
|
@@ -91,7 +93,8 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
91
93
|
"health_check_interval": 120,
|
|
92
94
|
"exit_timeout": 120,
|
|
93
95
|
"stop_timeout": 30,
|
|
94
|
-
"mcp_config_path": "~/.claude/slack-mcp.json"
|
|
96
|
+
"mcp_config_path": "~/.claude/slack-mcp.json",
|
|
97
|
+
"cozempic_prescription": "standard"
|
|
95
98
|
}
|
|
96
99
|
```
|
|
97
100
|
|
|
@@ -110,6 +113,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
110
113
|
| `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
|
|
111
114
|
| `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
|
|
112
115
|
| `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. |
|
|
116
|
+
| `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
|
|
113
117
|
|
|
114
118
|
---
|
|
115
119
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slack-channel-bots",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Multi-session Slack-to-Claude bridge
|
|
3
|
+
"version": "0.4.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"
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolve } from 'path'
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
19
|
export const MCP_SERVER_NAME = 'slack-channel-router'
|
|
20
|
+
export const ALLOWED_PRESCRIPTIONS = ['gentle', 'standard', 'aggressive']
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Types
|
|
@@ -41,6 +42,7 @@ export interface RoutingConfigInput {
|
|
|
41
42
|
stop_timeout?: number
|
|
42
43
|
mcp_config_path?: string
|
|
43
44
|
append_system_prompt_file?: string
|
|
45
|
+
cozempic_prescription?: string
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** Validated, fully-resolved routing configuration with all defaults applied. */
|
|
@@ -56,6 +58,7 @@ export interface RoutingConfig {
|
|
|
56
58
|
stop_timeout: number
|
|
57
59
|
mcp_config_path: string
|
|
58
60
|
append_system_prompt_file?: string
|
|
61
|
+
cozempic_prescription: string
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// ---------------------------------------------------------------------------
|
|
@@ -79,6 +82,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
|
|
|
79
82
|
stop_timeout: input.stop_timeout ?? 30,
|
|
80
83
|
mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
|
|
81
84
|
append_system_prompt_file: input.append_system_prompt_file,
|
|
85
|
+
cozempic_prescription: input.cozempic_prescription ?? 'standard',
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -152,6 +156,13 @@ export function validateConfig(config: RoutingConfig): void {
|
|
|
152
156
|
)
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
// cozempic_prescription must be one of the allowed values
|
|
160
|
+
if (!ALLOWED_PRESCRIPTIONS.includes(config.cozempic_prescription)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Routing config validation error: cozempic_prescription "${config.cozempic_prescription}" is invalid. Allowed values are: ${ALLOWED_PRESCRIPTIONS.join(', ')}.`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
// default_dm_session must reference an existing route CWD
|
|
156
167
|
if (config.default_dm_session !== undefined) {
|
|
157
168
|
if (!seen.has(config.default_dm_session)) {
|
package/src/cozempic.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cozempic.ts — Session cleaning via the cozempic CLI.
|
|
3
|
+
*
|
|
4
|
+
* Provides PATH availability checking, JSONL path resolution, file size
|
|
5
|
+
* inspection, and async session cleaning using `cozempic treat`.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'child_process'
|
|
11
|
+
import { statSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
13
|
+
import { ALLOWED_PRESCRIPTIONS } from './config.ts'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// PATH availability check
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let cozempicAvailable: boolean | null = null
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks whether `cozempic` is on PATH by spawning `which cozempic`.
|
|
23
|
+
* Sets the module-scoped flag and logs the result to stderr.
|
|
24
|
+
*/
|
|
25
|
+
export async function checkCozempicAvailable(): Promise<void> {
|
|
26
|
+
return new Promise<void>((resolve) => {
|
|
27
|
+
let settled = false
|
|
28
|
+
const done = (available: boolean, msg: string) => {
|
|
29
|
+
if (settled) return
|
|
30
|
+
settled = true
|
|
31
|
+
cozempicAvailable = available
|
|
32
|
+
console.error(msg)
|
|
33
|
+
resolve()
|
|
34
|
+
}
|
|
35
|
+
const proc = spawn('which', ['cozempic'])
|
|
36
|
+
proc.on('error', () => {
|
|
37
|
+
done(false, '[slack] Warning: cozempic not found on PATH — session cleaning disabled')
|
|
38
|
+
})
|
|
39
|
+
proc.on('close', (code) => {
|
|
40
|
+
if (code === 0) {
|
|
41
|
+
done(true, '[slack] cozempic available')
|
|
42
|
+
} else {
|
|
43
|
+
done(false, '[slack] Warning: cozempic not found on PATH — session cleaning disabled')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns true if cozempic was found on PATH during the last availability check. */
|
|
50
|
+
export function getCozempicAvailable(): boolean {
|
|
51
|
+
return cozempicAvailable === true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resets the availability flag to null (for testing). */
|
|
55
|
+
export function _resetCozempicAvailable(): void {
|
|
56
|
+
cozempicAvailable = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// JSONL path resolution
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds the absolute JSONL path for a given cwd and session ID.
|
|
65
|
+
* Pure function — no I/O, no validation.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveJsonlPath(cwd: string, sessionId: string): string {
|
|
68
|
+
const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
69
|
+
return `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// File size helper
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the size of the file at `path` in bytes, or null if the file
|
|
78
|
+
* cannot be stat'd (missing, permission error, etc.).
|
|
79
|
+
*/
|
|
80
|
+
export function readFileSizeBytes(path: string): number | null {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(path).size
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Session cleaning
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export type CleanSessionFn = (sessionId: string, cwd: string, prescription: string) => Promise<void>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs `cozempic treat <sessionId> -rx <prescription> --execute` against the
|
|
96
|
+
* session's JSONL file. Always resolves — never rejects.
|
|
97
|
+
*
|
|
98
|
+
* Skips if the JSONL is missing or empty. Streams cozempic output to stderr
|
|
99
|
+
* with the `[slack] cozempic:` prefix and logs before/after file sizes.
|
|
100
|
+
*/
|
|
101
|
+
export async function cleanSession(sessionId: string, cwd: string, prescription: string): Promise<void> {
|
|
102
|
+
if (!ALLOWED_PRESCRIPTIONS.includes(prescription)) {
|
|
103
|
+
console.error(`[slack] cozempic: invalid prescription "${prescription}" — skipping clean session=${sessionId}`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const path = resolveJsonlPath(cwd, sessionId)
|
|
107
|
+
const beforeSize = readFileSizeBytes(path)
|
|
108
|
+
|
|
109
|
+
if (beforeSize === null) {
|
|
110
|
+
console.error(`[slack] cozempic: JSONL not found — skipping clean session=${sessionId}`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (beforeSize === 0) {
|
|
115
|
+
console.error(`[slack] cozempic: JSONL empty — skipping clean session=${sessionId}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.error(`[slack] cozempic: cleaning started session=${sessionId} size=${beforeSize}`)
|
|
120
|
+
|
|
121
|
+
return new Promise<void>((resolve) => {
|
|
122
|
+
const proc = spawn('cozempic', ['treat', sessionId, '-rx', prescription, '--execute'])
|
|
123
|
+
|
|
124
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
125
|
+
for (const line of chunk.toString().split('\n')) {
|
|
126
|
+
if (line.length > 0) console.error(`[slack] cozempic: ${line}`)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
131
|
+
for (const line of chunk.toString().split('\n')) {
|
|
132
|
+
if (line.length > 0) console.error(`[slack] cozempic: ${line}`)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
proc.on('error', (err) => {
|
|
137
|
+
console.error(`[slack] cozempic: spawn error session=${sessionId}: ${err.message}`)
|
|
138
|
+
resolve()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
proc.on('close', (code) => {
|
|
142
|
+
const afterSize = readFileSizeBytes(path)
|
|
143
|
+
if (code !== 0 && code !== null) {
|
|
144
|
+
console.error(`[slack] cozempic: exit code ${code} session=${sessionId}`)
|
|
145
|
+
}
|
|
146
|
+
console.error(`[slack] cozempic: cleaning done session=${sessionId} size=${afterSize ?? 'unknown'}`)
|
|
147
|
+
resolve()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
}
|
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
|
@@ -42,6 +42,7 @@ import { loadConfig, expandTilde, type RoutingConfig, MCP_SERVER_NAME } from './
|
|
|
42
42
|
import { readSessions, writeSessions, rotateSessions } from './sessions.ts'
|
|
43
43
|
import { defaultTmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
44
44
|
import { startupSessionManager, launchSession } from './session-manager.ts'
|
|
45
|
+
import { cleanSession, getCozempicAvailable } from './cozempic.ts'
|
|
45
46
|
import {
|
|
46
47
|
initRestart,
|
|
47
48
|
scheduleRestart,
|
|
@@ -106,6 +107,43 @@ const STATE_DIR = process.env['SLACK_STATE_DIR'] || join(homedir(), '.claude', '
|
|
|
106
107
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
107
108
|
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
|
108
109
|
const PID_FILE = join(STATE_DIR, 'server.pid')
|
|
110
|
+
const KEEP_ALIVE_INTERVAL_MS = 30_000
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// SSE keep-alive — prevents idle stream disconnection
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const keepAliveTimers = new Map<WebStandardStreamableHTTPServerTransport, ReturnType<typeof setInterval>>()
|
|
117
|
+
|
|
118
|
+
export function startSseKeepAlive(transport: WebStandardStreamableHTTPServerTransport): void {
|
|
119
|
+
const id = setInterval(() => {
|
|
120
|
+
const streamEntry = (transport as any)._streamMapping?.get('_GET_stream')
|
|
121
|
+
if (streamEntry?.controller && streamEntry?.encoder) {
|
|
122
|
+
try {
|
|
123
|
+
streamEntry.controller.enqueue(streamEntry.encoder.encode(':ping\n\n'))
|
|
124
|
+
} catch {
|
|
125
|
+
clearInterval(id)
|
|
126
|
+
keepAliveTimers.delete(transport)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}, KEEP_ALIVE_INTERVAL_MS)
|
|
130
|
+
keepAliveTimers.set(transport, id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function stopSseKeepAlive(transport: WebStandardStreamableHTTPServerTransport): void {
|
|
134
|
+
const id = keepAliveTimers.get(transport)
|
|
135
|
+
if (id !== undefined) {
|
|
136
|
+
clearInterval(id)
|
|
137
|
+
keepAliveTimers.delete(transport)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function stopAllKeepAliveTimers(): void {
|
|
142
|
+
for (const id of keepAliveTimers.values()) {
|
|
143
|
+
clearInterval(id)
|
|
144
|
+
}
|
|
145
|
+
keepAliveTimers.clear()
|
|
146
|
+
}
|
|
109
147
|
|
|
110
148
|
// ---------------------------------------------------------------------------
|
|
111
149
|
// Bootstrap — tokens & state directory
|
|
@@ -273,6 +311,7 @@ const sessionToolDeps: SessionToolDeps = {
|
|
|
273
311
|
inboxDir: INBOX_DIR,
|
|
274
312
|
resolveUserName,
|
|
275
313
|
consumeAck,
|
|
314
|
+
serverPort: 0, // updated to actual port in main() before Bun.serve
|
|
276
315
|
}
|
|
277
316
|
|
|
278
317
|
// ---------------------------------------------------------------------------
|
|
@@ -298,6 +337,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
|
|
|
298
337
|
server: null as unknown as import('@modelcontextprotocol/sdk/server/index.js').Server,
|
|
299
338
|
deliveredChannels,
|
|
300
339
|
connected: true,
|
|
340
|
+
peerPort: 0,
|
|
301
341
|
}
|
|
302
342
|
|
|
303
343
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
@@ -306,6 +346,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
|
|
|
306
346
|
// Transport-level init. Roots resolution happens via server.oninitialized.
|
|
307
347
|
},
|
|
308
348
|
onsessionclosed: (mcpSessionId) => {
|
|
349
|
+
stopSseKeepAlive(transport)
|
|
309
350
|
// Session closed — clean up pending or registered state
|
|
310
351
|
const pending = getPendingSession(mcpSessionId)
|
|
311
352
|
if (pending) {
|
|
@@ -321,7 +362,8 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
|
|
|
321
362
|
: undefined
|
|
322
363
|
if (channelId) {
|
|
323
364
|
console.error(`[slack] Session disconnected: channel=${channelId} cwd="${cwd}"`)
|
|
324
|
-
|
|
365
|
+
const storedId = readSessions()[channelId]?.sessionId
|
|
366
|
+
scheduleRestart(channelId, cwd, storedId !== 'pending' ? storedId : undefined)
|
|
325
367
|
} else {
|
|
326
368
|
console.error(`[slack] Session disconnected: cwd="${cwd}"`)
|
|
327
369
|
}
|
|
@@ -330,6 +372,7 @@ function initPendingSession(): { pendingId: string; transport: WebStandardStream
|
|
|
330
372
|
})
|
|
331
373
|
|
|
332
374
|
entryStub.transport = transport
|
|
375
|
+
startSseKeepAlive(transport)
|
|
333
376
|
|
|
334
377
|
// Build the MCP server (closes over entryStub.deliveredChannels)
|
|
335
378
|
const server = createSessionServer(entryStub, sessionToolDeps)
|
|
@@ -813,6 +856,7 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
813
856
|
shuttingDown = true
|
|
814
857
|
stopHealthCheck()
|
|
815
858
|
cancelAllRestartTimers()
|
|
859
|
+
stopAllKeepAliveTimers()
|
|
816
860
|
|
|
817
861
|
console.error(`[slack] Received ${signal} — shutting down`)
|
|
818
862
|
|
|
@@ -915,6 +959,9 @@ export async function main(): Promise<void> {
|
|
|
915
959
|
await socket.start()
|
|
916
960
|
console.error('[slack] Socket Mode connected')
|
|
917
961
|
|
|
962
|
+
// Propagate resolved port to tool deps for peer PID discovery
|
|
963
|
+
sessionToolDeps.serverPort = mcpPort
|
|
964
|
+
|
|
918
965
|
// -------------------------------------------------------------------------
|
|
919
966
|
// HTTP server — single /mcp endpoint, roots-based session identity
|
|
920
967
|
// -------------------------------------------------------------------------
|
|
@@ -1281,6 +1328,12 @@ export async function main(): Promise<void> {
|
|
|
1281
1328
|
}
|
|
1282
1329
|
// entry is non-null here (null means init request, but we have a session ID)
|
|
1283
1330
|
|
|
1331
|
+
// Propagate peer port to registered sessions for tool call PID discovery
|
|
1332
|
+
if (entry !== null && 'channelId' in entry) {
|
|
1333
|
+
const remoteAddr = server.requestIP(req) as { address: string; port: number } | null
|
|
1334
|
+
if (remoteAddr?.port) (entry as SessionEntry).peerPort = remoteAddr.port
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1284
1337
|
// For GET requests (SSE streams), attach an abort listener to detect
|
|
1285
1338
|
// client disconnections. The MCP SDK's onsessionclosed only fires on
|
|
1286
1339
|
// explicit HTTP DELETE, so silent TCP/tmux kills are never detected
|
|
@@ -1302,7 +1355,8 @@ export async function main(): Promise<void> {
|
|
|
1302
1355
|
: undefined
|
|
1303
1356
|
if (channelId) {
|
|
1304
1357
|
console.error(`[slack] Session disconnected (SSE abort): channel=${channelId} cwd="${cwd}"`)
|
|
1305
|
-
|
|
1358
|
+
const storedId = readSessions()[channelId]?.sessionId
|
|
1359
|
+
scheduleRestart(channelId, cwd, storedId !== 'pending' ? storedId : undefined)
|
|
1306
1360
|
} else {
|
|
1307
1361
|
console.error(`[slack] Session disconnected (SSE abort): cwd="${cwd}"`)
|
|
1308
1362
|
}
|
|
@@ -1365,10 +1419,14 @@ export async function main(): Promise<void> {
|
|
|
1365
1419
|
},
|
|
1366
1420
|
launchSession: async (channelId, cwd, sessionId) => {
|
|
1367
1421
|
if (!routingConfig) return false
|
|
1368
|
-
const
|
|
1422
|
+
const stored = sessionId ?? readSessions()[channelId]?.sessionId
|
|
1423
|
+
// Treat "pending" as undefined — fall back to a fresh launch, not --resume
|
|
1424
|
+
const resolvedSessionId = stored !== 'pending' ? stored : undefined
|
|
1369
1425
|
const record = await launchSession(
|
|
1370
1426
|
channelId, cwd, routingConfig, defaultTmuxClient,
|
|
1371
|
-
resolvedSessionId !== undefined
|
|
1427
|
+
resolvedSessionId !== undefined
|
|
1428
|
+
? { sessionId: resolvedSessionId, cleanSession: getCozempicAvailable() ? cleanSession : undefined }
|
|
1429
|
+
: undefined,
|
|
1372
1430
|
)
|
|
1373
1431
|
if (record) {
|
|
1374
1432
|
const sessions = readSessions()
|
package/src/session-manager.ts
CHANGED
|
@@ -9,12 +9,27 @@
|
|
|
9
9
|
* SPDX-License-Identifier: MIT
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { type
|
|
12
|
+
import { accessSync, existsSync, constants } from 'fs'
|
|
13
|
+
import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
14
|
+
import { type CleanSessionFn, checkCozempicAvailable, getCozempicAvailable, cleanSession as defaultCleanSession, resolveJsonlPath } from './cozempic.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 path = resolveJsonlPath(cwd, sessionId)
|
|
30
|
+
return existsSync(path)
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
// ---------------------------------------------------------------------------
|
|
19
34
|
// Types
|
|
20
35
|
// ---------------------------------------------------------------------------
|
|
@@ -24,6 +39,8 @@ export interface LaunchOptions {
|
|
|
24
39
|
pollTimeout?: number
|
|
25
40
|
/** Claude session UUID to resume. When provided, --resume <id> is appended to the CLI command. */
|
|
26
41
|
sessionId?: string
|
|
42
|
+
/** Optional session cleaning function to run before --resume launches. */
|
|
43
|
+
cleanSession?: CleanSessionFn
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
export interface SessionStateResult {
|
|
@@ -78,9 +95,10 @@ export async function launchSession(
|
|
|
78
95
|
const PROMPT_TEXT = 'I am using this for local development'
|
|
79
96
|
const NO_CONVERSATION_TEXT = 'No conversation found'
|
|
80
97
|
|
|
81
|
-
// Inner helper: sends the launch command and polls for the safety prompt
|
|
82
|
-
//
|
|
83
|
-
//
|
|
98
|
+
// Inner helper: sends the launch command and polls for the safety prompt.
|
|
99
|
+
// Returns a SessionRecord immediately after the safety prompt is acknowledged.
|
|
100
|
+
// Resume path: sessionId = safeResumeId. Fresh path: sessionId = "pending".
|
|
101
|
+
// Returns null on timeout (safety prompt never appeared) or capturePane failure.
|
|
84
102
|
async function attemptLaunch(
|
|
85
103
|
withResumeId: string | undefined,
|
|
86
104
|
sessionName_: string,
|
|
@@ -101,7 +119,6 @@ export async function launchSession(
|
|
|
101
119
|
console.error(`[slack] Claude launch command sent to session: ${sessionName_}`)
|
|
102
120
|
|
|
103
121
|
let delay = POLL_START_MS
|
|
104
|
-
let promptAcknowledged = false
|
|
105
122
|
|
|
106
123
|
while (Date.now() < launchDeadline) {
|
|
107
124
|
await new Promise<void>((resolve) => setTimeout(resolve, delay))
|
|
@@ -115,46 +132,23 @@ export async function launchSession(
|
|
|
115
132
|
return null
|
|
116
133
|
}
|
|
117
134
|
|
|
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
135
|
// Fast-fail: "No conversation found" means the resume failed
|
|
125
136
|
if (pane.includes(NO_CONVERSATION_TEXT)) {
|
|
126
137
|
console.error(`[slack] "No conversation found" detected — fast-fail resume for channel=${channelId}`)
|
|
127
138
|
return 'NO_CONVERSATION' as unknown as SessionRecord
|
|
128
139
|
}
|
|
129
140
|
|
|
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
|
|
141
|
+
if (pane.includes(PROMPT_TEXT)) {
|
|
142
|
+
await tmuxClient.sendKeys(sessionName_, 'Enter')
|
|
143
|
+
console.error(`[slack] Safety prompt acknowledged in session: ${sessionName_}`)
|
|
144
|
+
return {
|
|
145
|
+
tmuxSession: sessionName_,
|
|
146
|
+
lastLaunch: new Date().toISOString(),
|
|
147
|
+
sessionId: safeResumeId ?? 'pending',
|
|
153
148
|
}
|
|
154
149
|
}
|
|
155
150
|
}
|
|
156
151
|
|
|
157
|
-
console.error(`[slack] Timed out waiting for session ID for channel=${channelId}`)
|
|
158
152
|
return null
|
|
159
153
|
}
|
|
160
154
|
|
|
@@ -162,6 +156,11 @@ export async function launchSession(
|
|
|
162
156
|
await tmuxClient.newSession(name, cwd)
|
|
163
157
|
console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
|
|
164
158
|
|
|
159
|
+
// Clean JSONL before resuming (if a cleanSession fn was provided)
|
|
160
|
+
if (resumeSessionId && resumeSessionId !== 'pending' && options?.cleanSession) {
|
|
161
|
+
await options.cleanSession(resumeSessionId, cwd, routingConfig.cozempic_prescription)
|
|
162
|
+
}
|
|
163
|
+
|
|
165
164
|
// Attempt launch (with --resume if sessionId provided)
|
|
166
165
|
let result = await attemptLaunch(resumeSessionId, name)
|
|
167
166
|
|
|
@@ -234,6 +233,8 @@ export async function startupSessionManager(
|
|
|
234
233
|
|
|
235
234
|
console.error(`[slack] startupSessionManager: storedSessions=${JSON.stringify(storedSessions)}`)
|
|
236
235
|
|
|
236
|
+
await checkCozempicAvailable()
|
|
237
|
+
|
|
237
238
|
const routeEntries = Object.entries(routingConfig.routes)
|
|
238
239
|
const concurrency = options?.concurrency ?? 3
|
|
239
240
|
const startupTimeout = options?.startupTimeout ?? 60_000
|
|
@@ -256,41 +257,22 @@ export async function startupSessionManager(
|
|
|
256
257
|
|
|
257
258
|
if (running) {
|
|
258
259
|
// Branch 1: Reconnect — session live, send /mcp reconnect <server-name>
|
|
259
|
-
const
|
|
260
|
+
const storedId = storedSessions[channelId]?.sessionId
|
|
261
|
+
const reconnectSessionId = storedId ?? 'none'
|
|
260
262
|
console.error(`[slack] startupSessionManager: branch=reconnect channel=${channelId} sessionId=${reconnectSessionId}`)
|
|
261
263
|
console.error(`[slack] Session live — reconnecting MCP server "${MCP_SERVER_NAME}": channel=${channelId} session=${name}`)
|
|
262
264
|
await tmuxClient.sendKeys(name, `/mcp reconnect ${MCP_SERVER_NAME}`, 'Enter')
|
|
263
265
|
|
|
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++
|
|
266
|
+
// Use stored session ID; fall back to "pending" if absent or already pending.
|
|
267
|
+
// The tool call hook (Epic 1) will update it to the real ID on the next tool call.
|
|
268
|
+
const sessionId = (storedId && storedId !== 'pending') ? storedId : 'pending'
|
|
269
|
+
console.error(`[slack] startupSessionManager: reconnect using sessionId=${sessionId} for channel=${channelId} (${Date.now() - routeStart}ms)`)
|
|
270
|
+
resultMap.set(channelId, {
|
|
271
|
+
tmuxSession: name,
|
|
272
|
+
lastLaunch: new Date().toISOString(),
|
|
273
|
+
sessionId,
|
|
274
|
+
})
|
|
275
|
+
succeeded++
|
|
294
276
|
return
|
|
295
277
|
}
|
|
296
278
|
}
|
|
@@ -309,17 +291,22 @@ export async function startupSessionManager(
|
|
|
309
291
|
const effectiveTimeout = Math.min(options?.pollTimeout ?? 120_000, startupTimeout)
|
|
310
292
|
const launchOpts = { ...options, pollTimeout: effectiveTimeout }
|
|
311
293
|
|
|
312
|
-
|
|
294
|
+
const shouldResume = !!(storedSessionId && storedSessionId !== 'pending' && jsonlExistsForSession(route.cwd, storedSessionId))
|
|
295
|
+
if (!shouldResume && storedSessionId && storedSessionId !== 'pending') {
|
|
296
|
+
console.error(`[slack] startupSessionManager: no JSONL for stored session — skipping resume: channel=${channelId} sessionId=${storedSessionId}`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (shouldResume) {
|
|
313
300
|
// Branch 2: Resume — launch with stored session ID
|
|
314
301
|
console.error(`[slack] startupSessionManager: branch=resume channel=${channelId} sessionId=${storedSessionId}`)
|
|
315
302
|
console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
|
|
316
303
|
const record = await launchSession(
|
|
317
304
|
channelId, route.cwd, routingConfig, tmuxClient,
|
|
318
|
-
{ ...launchOpts, sessionId: storedSessionId },
|
|
305
|
+
{ ...launchOpts, sessionId: storedSessionId, cleanSession: getCozempicAvailable() ? defaultCleanSession : undefined },
|
|
319
306
|
)
|
|
320
307
|
const elapsed = Date.now() - routeStart
|
|
321
308
|
if (record !== null) {
|
|
322
|
-
console.error(`[slack] startupSessionManager: channel=${channelId} resumed in ${elapsed}ms`)
|
|
309
|
+
console.error(`[slack] startupSessionManager: channel=${channelId} resumed in ${elapsed}ms (storedId=${storedSessionId} discoveredId=${record.sessionId})`)
|
|
323
310
|
resultMap.set(channelId, record)
|
|
324
311
|
succeeded++
|
|
325
312
|
} else {
|