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 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.2.1",
4
- "description": "Multi-session Slack-to-Claude bridge run multiple Claude Code bots across Slack channels via Socket Mode",
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)) {
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ import { homedir } from 'os'
2
+ import { join } from 'path'
3
+ import { readFile } from 'fs/promises'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // getPeerPidByPort
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Discover the PID of a process that has an established TCP connection
11
+ * from serverPort to peerPort on the loopback interface (IPv4 or IPv6).
12
+ *
13
+ * Runs: ss -tnp
14
+ * Filters for lines where local addr is 127.0.0.1:<serverPort> or ::1:<serverPort>
15
+ * and peer addr is 127.0.0.1:<peerPort> or ::1:<peerPort>.
16
+ *
17
+ * Returns the PID as a number, or null if not found or on any error.
18
+ */
19
+ export async function getPeerPidByPort(
20
+ peerPort: number,
21
+ serverPort: number,
22
+ ): Promise<number | null> {
23
+ try {
24
+ const proc = Bun.spawn(['ss', '-tnp'], { stdout: 'pipe', stderr: 'pipe' })
25
+ const text = await new Response(proc.stdout).text()
26
+
27
+ for (const line of text.split('\n')) {
28
+ if (!matchesPorts(line, peerPort, serverPort)) continue
29
+ const pid = parsePid(line)
30
+ if (pid !== null) return pid
31
+ }
32
+ return null
33
+ } catch (err) {
34
+ console.error('[slack] peer-pid: getPeerPidByPort failed', err)
35
+ return null
36
+ }
37
+ }
38
+
39
+ /** Returns true if the ss output line matches the expected port pair on loopback. */
40
+ function matchesPorts(line: string, peerPort: number, serverPort: number): boolean {
41
+ // ss -tnp columns: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port ...
42
+ // Accept both 127.0.0.1 and ::1 loopback addresses.
43
+ const loopback = '(?:127\\.0\\.0\\.1|\\[?::1\\]?)'
44
+ const local = new RegExp(`${loopback}:${serverPort}(?:\\s|$)`)
45
+ const peer = new RegExp(`${loopback}:${peerPort}(?:\\s|$)`)
46
+ return local.test(line) && peer.test(line)
47
+ }
48
+
49
+ /**
50
+ * Parse PID from ss users field, e.g.:
51
+ * users:(("bun",pid=12345,fd=7))
52
+ * Returns the PID as a number, or null if unparseable.
53
+ */
54
+ function parsePid(line: string): number | null {
55
+ const m = line.match(/pid=(\d+)/)
56
+ if (!m) return null
57
+ const pid = parseInt(m[1], 10)
58
+ return isNaN(pid) ? null : pid
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // getSessionIdForPid
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Read the Claude Code session ID stored at ~/.claude/sessions/<pid>.json.
67
+ * Returns the sessionId string, or null if the file is missing, unreadable,
68
+ * or does not contain a valid sessionId string field.
69
+ *
70
+ * Never throws.
71
+ */
72
+ export async function getSessionIdForPid(pid: number): Promise<string | null> {
73
+ try {
74
+ const filePath = join(homedir(), '.claude', 'sessions', `${pid}.json`)
75
+ const raw = await readFile(filePath, 'utf-8')
76
+ const parsed = JSON.parse(raw)
77
+ if (parsed && typeof parsed.sessionId === 'string') return parsed.sessionId
78
+ return null
79
+ } catch (err) {
80
+ console.error('[slack] peer-pid: getSessionIdForPid failed', err)
81
+ return null
82
+ }
83
+ }
package/src/registry.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js'
17
17
  import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
18
+ import { getPeerPidByPort, getSessionIdForPid } from './peer-pid.ts'
19
+ import { readSessions, writeSessions, type SessionsMap } from './sessions.ts'
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Types
@@ -37,6 +39,12 @@ export interface SessionEntry {
37
39
  deliveredChannels: Set<string>
38
40
  /** Whether the session is currently connected (transport alive) */
39
41
  connected: boolean
42
+ /**
43
+ * TCP peer port of the most recent MCP request from this session.
44
+ * Updated per-request in server.ts before transport.handleRequest().
45
+ * Used by the CallToolRequestSchema handler to identify the calling process.
46
+ */
47
+ peerPort: number
40
48
  }
41
49
 
42
50
  /**
@@ -138,6 +146,7 @@ export function registerSession(
138
146
  server: resolvedServer,
139
147
  deliveredChannels,
140
148
  connected: true,
149
+ peerPort: 0,
141
150
  }
142
151
  registry.set(cwd, entry)
143
152
  return entry
@@ -323,6 +332,16 @@ export interface SessionToolDeps {
323
332
  resolveUserName: (userId: string) => Promise<string>
324
333
  /** Consume a pending ack entry — returns true if it existed */
325
334
  consumeAck: (channelId: string, messageTs: string) => boolean
335
+ /** TCP port the MCP HTTP server is listening on — used for peer PID discovery */
336
+ serverPort: number
337
+ /** Injectable for testing: replaces getPeerPidByPort in the discovery hook */
338
+ getPeerPidByPort?: (peerPort: number, serverPort: number) => Promise<number | null>
339
+ /** Injectable for testing: replaces getSessionIdForPid in the discovery hook */
340
+ getSessionIdForPid?: (pid: number) => Promise<string | null>
341
+ /** Injectable for testing: replaces readSessions in the discovery hook */
342
+ readSessions?: () => SessionsMap
343
+ /** Injectable for testing: replaces writeSessions in the discovery hook */
344
+ writeSessions?: (sessions: SessionsMap) => void
326
345
  }
327
346
 
328
347
  const MCP_INSTRUCTIONS = [
@@ -351,6 +370,10 @@ export function createSessionServer(
351
370
  deps: SessionToolDeps,
352
371
  ): Server {
353
372
  const { web, assertOutboundAllowed, assertSendable, getAccess, resolveUserName, inboxDir, consumeAck } = deps
373
+ const _getPeerPid = deps.getPeerPidByPort ?? getPeerPidByPort
374
+ const _getSessionId = deps.getSessionIdForPid ?? getSessionIdForPid
375
+ const _readSessions = deps.readSessions ?? readSessions
376
+ const _writeSessions = deps.writeSessions ?? writeSessions
354
377
 
355
378
  const server = new Server(
356
379
  { name: MCP_SERVER_NAME, version: '0.1.0' },
@@ -478,7 +501,8 @@ export function createSessionServer(
478
501
 
479
502
  const DEFAULT_CHUNK_LIMIT = 4000
480
503
 
481
- switch (name) {
504
+ // Wrap switch in IIFE so we can trigger post-call discovery after the result is computed
505
+ const result = await (async () => { switch (name) {
482
506
  // ---------------------------------------------------------------------
483
507
  // reply
484
508
  // ---------------------------------------------------------------------
@@ -672,7 +696,36 @@ export function createSessionServer(
672
696
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
673
697
  isError: true,
674
698
  }
699
+ } })() // end IIFE
700
+
701
+ // -------------------------------------------------------------------------
702
+ // Post-call: fire-and-forget peer PID → session ID discovery (t2.nrd.so.a7)
703
+ // Never blocks or delays the tool call response.
704
+ // -------------------------------------------------------------------------
705
+ if (entry.peerPort > 0) {
706
+ const peerPort = entry.peerPort
707
+ const serverPort = deps.serverPort
708
+ const channelId = entry.channelId
709
+ ;(async () => {
710
+ try {
711
+ const pid = await _getPeerPid(peerPort, serverPort)
712
+ if (pid === null) return
713
+ const sessionId = await _getSessionId(pid)
714
+ if (!sessionId) return
715
+ const sessions = _readSessions()
716
+ const record = sessions[channelId]
717
+ if (!record) return
718
+ if (record.sessionId === sessionId) return
719
+ sessions[channelId] = { ...record, sessionId }
720
+ _writeSessions(sessions)
721
+ console.error(`[slack] peer-pid: updated sessionId for channel=${channelId} pid=${pid}`)
722
+ } catch (err) {
723
+ console.error('[slack] peer-pid: session discovery failed', err)
724
+ }
725
+ })()
675
726
  }
727
+
728
+ return result
676
729
  })
677
730
 
678
731
  return server
package/src/server.ts CHANGED
@@ -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
- scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
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
- scheduleRestart(channelId, cwd, readSessions()[channelId]?.sessionId)
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 resolvedSessionId = sessionId ?? readSessions()[channelId]?.sessionId
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 ? { sessionId: 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()
@@ -9,12 +9,27 @@
9
9
  * SPDX-License-Identifier: MIT
10
10
  */
11
11
 
12
- import { readFileSync, accessSync, constants } from 'fs'
13
- import { homedir } from 'node:os'
14
- import { type TmuxClient, sessionName, isClaudeRunning, getClaudePid } from './tmux.ts'
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
- // then continues polling for Claude PID and session file discovery.
83
- // Returns the discovered SessionRecord on success, or null on failure/timeout.
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
- // Try to discover session ID via PID
131
- const pid = await getClaudePid(sessionName_, tmuxClient)
132
- if (pid !== null) {
133
- const sessionFilePath = `${homedir()}/.claude/sessions/${pid}.json`
134
- try {
135
- const raw = readFileSync(sessionFilePath, 'utf-8')
136
- const entry = JSON.parse(raw)
137
- if (
138
- typeof entry === 'object' &&
139
- entry !== null &&
140
- typeof entry.sessionId === 'string' &&
141
- entry.sessionId.length > 0
142
- ) {
143
- const foundId = entry.sessionId as string
144
- console.error(`[slack] launchSession: discovered sessionId=${foundId} via PID=${pid} for channel=${channelId}`)
145
- return {
146
- tmuxSession: sessionName_,
147
- lastLaunch: new Date().toISOString(),
148
- sessionId: foundId,
149
- }
150
- }
151
- } catch {
152
- // Session file not yet written — keep polling
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 reconnectSessionId = storedSessions[channelId]?.sessionId ?? 'none'
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
- // Discover session ID via PID-based file lookup
265
- const pid = await getClaudePid(name, tmuxClient)
266
- if (pid !== null) {
267
- const sessionFilePath = `${homedir()}/.claude/sessions/${pid}.json`
268
- try {
269
- const raw = readFileSync(sessionFilePath, 'utf-8')
270
- const entry = JSON.parse(raw)
271
- if (
272
- typeof entry === 'object' &&
273
- entry !== null &&
274
- typeof entry.sessionId === 'string' &&
275
- entry.sessionId.length > 0
276
- ) {
277
- const foundId = entry.sessionId as string
278
- console.error(`[slack] startupSessionManager: reconnect discovered sessionId=${foundId} via PID=${pid} for channel=${channelId} (${Date.now() - routeStart}ms)`)
279
- resultMap.set(channelId, {
280
- tmuxSession: name,
281
- lastLaunch: new Date().toISOString(),
282
- sessionId: foundId,
283
- })
284
- succeeded++
285
- return
286
- }
287
- } catch {
288
- console.error(`[slack] startupSessionManager: reconnect — could not read session file for PID=${pid} channel=${channelId}`)
289
- }
290
- } else {
291
- console.error(`[slack] startupSessionManager: reconnect — no claude PID found for channel=${channelId}`)
292
- }
293
- failed++
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
- if (storedSessionId && storedSessionId !== 'pending') {
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 {