claude-slack-channel-bots 0.3.0 → 0.4.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 CHANGED
@@ -42,6 +42,7 @@ See the sections below for manual configuration details if you prefer not to use
42
42
  - `ss` from [iproute2](https://github.com/iproute2/iproute2) on your `PATH` (required for session ID discovery; pre-installed on most Linux distributions)
43
43
  - `curl` and `jq` on your `PATH` (required for the permission relay hooks)
44
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
45
46
 
46
47
  ---
47
48
 
@@ -57,6 +58,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
57
58
  | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
58
59
  | `SLACK_STATE_DIR` | Override the directory where `routing.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
59
60
  | `SLACK_ACCESS_MODE` | Set to `static` to load `access.json` once at startup and cache it for the lifetime of the process rather than re-reading it on every event. Useful in high-throughput environments where disk reads are a concern. |
61
+ | `SLACK_DRY_RUN` | Set to `1` to start the server without Slack credentials. Token validation is skipped, Socket Mode and `web.auth.test()` are not called, and MCP tool calls (`reply`, `react`, etc.) are logged instead of sent. Useful for integration testing. |
60
62
 
61
63
  Shell profile example:
62
64
 
@@ -66,6 +68,8 @@ export SLACK_APP_TOKEN=xapp-your-app-token
66
68
  # Optional overrides:
67
69
  export SLACK_STATE_DIR=~/.config/slack-channel-bots
68
70
  export SLACK_ACCESS_MODE=static
71
+ # Dry-run mode (no Slack credentials needed):
72
+ export SLACK_DRY_RUN=1
69
73
  ```
70
74
 
71
75
  ---
@@ -92,7 +96,8 @@ A skeleton file is created by postinstall. Populate it before running `start`.
92
96
  "health_check_interval": 120,
93
97
  "exit_timeout": 120,
94
98
  "stop_timeout": 30,
95
- "mcp_config_path": "~/.claude/slack-mcp.json"
99
+ "mcp_config_path": "~/.claude/slack-mcp.json",
100
+ "cozempic_prescription": "standard"
96
101
  }
97
102
  ```
98
103
 
@@ -111,6 +116,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
111
116
  | `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
112
117
  | `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
113
118
  | `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. |
119
+ | `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
114
120
 
115
121
  ---
116
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
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": {
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning, sessionName
18
18
  import { readSessions, type SessionsMap } from './sessions.ts'
19
19
  import { loadConfig as configLoadConfig, type RoutingConfig } from './config.ts'
20
20
  import { initLogging } from './logging.ts'
21
+ import { isDryRun } from './tokens.ts'
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Injectable dependency interface
@@ -92,14 +93,16 @@ export function createCli(deps: CliDeps): CliHandlers {
92
93
  deps.exit(1)
93
94
  }
94
95
 
95
- // Check required Slack tokens
96
- if (!deps.env['SLACK_BOT_TOKEN']) {
97
- console.error('missing prerequisite: SLACK_BOT_TOKEN environment variable')
98
- deps.exit(1)
99
- }
100
- if (!deps.env['SLACK_APP_TOKEN']) {
101
- console.error('missing prerequisite: SLACK_APP_TOKEN environment variable')
102
- deps.exit(1)
96
+ // Check required Slack tokens (skipped in dry-run mode)
97
+ if (!isDryRun()) {
98
+ if (!deps.env['SLACK_BOT_TOKEN']) {
99
+ console.error('missing prerequisite: SLACK_BOT_TOKEN environment variable')
100
+ deps.exit(1)
101
+ }
102
+ if (!deps.env['SLACK_APP_TOKEN']) {
103
+ console.error('missing prerequisite: SLACK_APP_TOKEN environment variable')
104
+ deps.exit(1)
105
+ }
103
106
  }
104
107
 
105
108
  // Check routing.json exists
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
+ }
package/src/registry.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js'
17
17
  import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
18
+ import { isDryRun } from './tokens.ts'
18
19
  import { getPeerPidByPort, getSessionIdForPid } from './peer-pid.ts'
19
20
  import { readSessions, writeSessions, type SessionsMap } from './sessions.ts'
20
21
 
@@ -516,6 +517,11 @@ export function createSessionServer(
516
517
  // Per-session outbound gate (t2.c1r.zk.qm)
517
518
  assertOutboundAllowed(chatId, entry.deliveredChannels)
518
519
 
520
+ if (isDryRun()) {
521
+ console.error(`[slack] dry-run: reply to ${chatId} (${text.length} chars)`)
522
+ return { content: [{ type: 'text', text: `[dry-run] Would send message to ${chatId}` }] }
523
+ }
524
+
519
525
  const access = getAccess()
520
526
  const limit = access.textChunkLimit || DEFAULT_CHUNK_LIMIT
521
527
  const mode = access.chunkMode || 'newline'
@@ -575,6 +581,10 @@ export function createSessionServer(
575
581
  // ---------------------------------------------------------------------
576
582
  case 'react': {
577
583
  assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
584
+ if (isDryRun()) {
585
+ console.error(`[slack] dry-run: react :${args.emoji}: on ${args.message_id}`)
586
+ return { content: [{ type: 'text', text: `[dry-run] Would react :${args.emoji}: to ${args.message_id}` }] }
587
+ }
578
588
  await web.reactions.add({
579
589
  channel: args.chat_id,
580
590
  timestamp: args.message_id,
@@ -590,6 +600,10 @@ export function createSessionServer(
590
600
  // ---------------------------------------------------------------------
591
601
  case 'edit_message': {
592
602
  assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
603
+ if (isDryRun()) {
604
+ console.error(`[slack] dry-run: edit_message ${args.message_id} in ${args.chat_id}`)
605
+ return { content: [{ type: 'text', text: `[dry-run] Would edit message ${args.message_id}` }] }
606
+ }
593
607
  await web.chat.update({
594
608
  channel: args.chat_id,
595
609
  ts: args.message_id,
@@ -605,6 +619,10 @@ export function createSessionServer(
605
619
  // ---------------------------------------------------------------------
606
620
  case 'fetch_messages': {
607
621
  assertOutboundAllowed(args.channel, entry.deliveredChannels)
622
+ if (isDryRun()) {
623
+ console.error(`[slack] dry-run: fetch_messages from ${args.channel}`)
624
+ return { content: [{ type: 'text', text: `[dry-run] Would fetch messages from ${args.channel}` }] }
625
+ }
608
626
  const channel: string = args.channel
609
627
  const limit = Math.min(args.limit || 20, 100)
610
628
  const threadTs: string | undefined = args.thread_ts
@@ -646,6 +664,10 @@ export function createSessionServer(
646
664
  // ---------------------------------------------------------------------
647
665
  case 'download_attachment': {
648
666
  assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
667
+ if (isDryRun()) {
668
+ console.error(`[slack] dry-run: download_attachment from ${args.chat_id} msg=${args.message_id}`)
669
+ return { content: [{ type: 'text', text: `[dry-run] Would download attachments from ${args.message_id}` }] }
670
+ }
649
671
  const channel: string = args.chat_id
650
672
  const messageTs: string = args.message_id
651
673
 
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,
@@ -51,7 +52,7 @@ import {
51
52
  hasReachedMaxFailures,
52
53
  } from './restart.ts'
53
54
  import { initHealthCheck, startHealthCheck, stopHealthCheck } from './health-check.ts'
54
- import { loadTokens } from './tokens.ts'
55
+ import { loadTokens, isDryRun } from './tokens.ts'
55
56
  import { checkPidConflict, writePidFile, removePidFile } from './pid.ts'
56
57
  import { trackAck, consumeAck } from './ack-tracker.ts'
57
58
  import {
@@ -946,17 +947,22 @@ export async function main(): Promise<void> {
946
947
  }
947
948
  }
948
949
 
949
- // Resolve bot user ID
950
- try {
951
- const auth = await web.auth.test()
952
- botUserId = (auth.user_id as string) || ''
953
- } catch (err) {
954
- console.error('[slack] Failed to resolve bot user ID:', err)
955
- }
950
+ if (isDryRun()) {
951
+ console.error('[slack] Running in dry-run mode — Slack disabled')
952
+ botUserId = 'U000DRY'
953
+ } else {
954
+ // Resolve bot user ID
955
+ try {
956
+ const auth = await web.auth.test()
957
+ botUserId = (auth.user_id as string) || ''
958
+ } catch (err) {
959
+ console.error('[slack] Failed to resolve bot user ID:', err)
960
+ }
956
961
 
957
- // Connect Socket Mode
958
- await socket.start()
959
- console.error('[slack] Socket Mode connected')
962
+ // Connect Socket Mode
963
+ await socket.start()
964
+ console.error('[slack] Socket Mode connected')
965
+ }
960
966
 
961
967
  // Propagate resolved port to tool deps for peer PID discovery
962
968
  sessionToolDeps.serverPort = mcpPort
@@ -1103,6 +1109,17 @@ export async function main(): Promise<void> {
1103
1109
  // Generate unique request ID
1104
1110
  const requestId = crypto.randomUUID()
1105
1111
 
1112
+ // Register pending entry BEFORE posting to Slack to avoid race condition:
1113
+ // If the user clicks Allow/Deny before postMessage returns, the interactive
1114
+ // handler needs the entry to already exist in pendingPermissions.
1115
+ pendingPermissions.set(requestId, {
1116
+ requestId,
1117
+ channelId: matchedChannelId,
1118
+ messageTs: '',
1119
+ toolName: tool_name,
1120
+ waiters: [],
1121
+ })
1122
+
1106
1123
  // Post Block Kit message to channel
1107
1124
  let messageTs: string
1108
1125
  try {
@@ -1114,6 +1131,7 @@ export async function main(): Promise<void> {
1114
1131
  })
1115
1132
  messageTs = postResult.ts as string
1116
1133
  } catch (err) {
1134
+ pendingPermissions.delete(requestId)
1117
1135
  console.error('[slack] /permission: chat.postMessage failed:', err)
1118
1136
  return new Response(JSON.stringify({ error: 'Failed to post message' }), {
1119
1137
  status: 500,
@@ -1121,14 +1139,9 @@ export async function main(): Promise<void> {
1121
1139
  })
1122
1140
  }
1123
1141
 
1124
- // Register pending entry with empty waiters array
1125
- pendingPermissions.set(requestId, {
1126
- requestId,
1127
- channelId: matchedChannelId,
1128
- messageTs,
1129
- toolName: tool_name,
1130
- waiters: [],
1131
- })
1142
+ // Update with the real message timestamp (needed for chat.update on decision)
1143
+ const pending = pendingPermissions.get(requestId)
1144
+ if (pending) pending.messageTs = messageTs
1132
1145
 
1133
1146
  return new Response(JSON.stringify({ requestId }), {
1134
1147
  status: 200,
@@ -1268,6 +1281,17 @@ export async function main(): Promise<void> {
1268
1281
  },
1269
1282
  ]
1270
1283
 
1284
+ // Register pending entry BEFORE posting to Slack to avoid race condition:
1285
+ // If the user clicks a button before postMessage returns, the interactive
1286
+ // handler needs the entry to already exist in pendingQuestions.
1287
+ pendingQuestions.set(requestId, {
1288
+ requestId,
1289
+ channelId: matchedChannelId,
1290
+ messageTs: '',
1291
+ question: question as string,
1292
+ waiters: [],
1293
+ })
1294
+
1271
1295
  let messageTs: string
1272
1296
  try {
1273
1297
  const postResult = await web.chat.postMessage({
@@ -1277,6 +1301,7 @@ export async function main(): Promise<void> {
1277
1301
  })
1278
1302
  messageTs = postResult.ts as string
1279
1303
  } catch (err) {
1304
+ pendingQuestions.delete(requestId)
1280
1305
  console.error('[slack] /ask: chat.postMessage failed:', err)
1281
1306
  return new Response(JSON.stringify({ error: 'Failed to post message' }), {
1282
1307
  status: 500,
@@ -1284,13 +1309,9 @@ export async function main(): Promise<void> {
1284
1309
  })
1285
1310
  }
1286
1311
 
1287
- pendingQuestions.set(requestId, {
1288
- requestId,
1289
- channelId: matchedChannelId,
1290
- messageTs,
1291
- question: question as string,
1292
- waiters: [],
1293
- })
1312
+ // Update with the real message timestamp
1313
+ const pendingQ = pendingQuestions.get(requestId)
1314
+ if (pendingQ) pendingQ.messageTs = messageTs
1294
1315
 
1295
1316
  return new Response(JSON.stringify({ requestId }), {
1296
1317
  status: 200,
@@ -1423,7 +1444,9 @@ export async function main(): Promise<void> {
1423
1444
  const resolvedSessionId = stored !== 'pending' ? stored : undefined
1424
1445
  const record = await launchSession(
1425
1446
  channelId, cwd, routingConfig, defaultTmuxClient,
1426
- resolvedSessionId !== undefined ? { sessionId: resolvedSessionId } : undefined,
1447
+ resolvedSessionId !== undefined
1448
+ ? { sessionId: resolvedSessionId, cleanSession: getCozempicAvailable() ? cleanSession : undefined }
1449
+ : undefined,
1427
1450
  )
1428
1451
  if (record) {
1429
1452
  const sessions = readSessions()
@@ -10,8 +10,8 @@
10
10
  */
11
11
 
12
12
  import { accessSync, existsSync, constants } from 'fs'
13
- import { homedir } from 'node:os'
14
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
 
@@ -26,8 +26,7 @@ import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
26
26
  */
27
27
  export function jsonlExistsForSession(cwd: string, sessionId: string): boolean {
28
28
  if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) return false
29
- const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-')
30
- const path = `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
29
+ const path = resolveJsonlPath(cwd, sessionId)
31
30
  return existsSync(path)
32
31
  }
33
32
 
@@ -40,6 +39,8 @@ export interface LaunchOptions {
40
39
  pollTimeout?: number
41
40
  /** Claude session UUID to resume. When provided, --resume <id> is appended to the CLI command. */
42
41
  sessionId?: string
42
+ /** Optional session cleaning function to run before --resume launches. */
43
+ cleanSession?: CleanSessionFn
43
44
  }
44
45
 
45
46
  export interface SessionStateResult {
@@ -155,6 +156,11 @@ export async function launchSession(
155
156
  await tmuxClient.newSession(name, cwd)
156
157
  console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
157
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
+
158
164
  // Attempt launch (with --resume if sessionId provided)
159
165
  let result = await attemptLaunch(resumeSessionId, name)
160
166
 
@@ -227,6 +233,8 @@ export async function startupSessionManager(
227
233
 
228
234
  console.error(`[slack] startupSessionManager: storedSessions=${JSON.stringify(storedSessions)}`)
229
235
 
236
+ await checkCozempicAvailable()
237
+
230
238
  const routeEntries = Object.entries(routingConfig.routes)
231
239
  const concurrency = options?.concurrency ?? 3
232
240
  const startupTimeout = options?.startupTimeout ?? 60_000
@@ -294,7 +302,7 @@ export async function startupSessionManager(
294
302
  console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
295
303
  const record = await launchSession(
296
304
  channelId, route.cwd, routingConfig, tmuxClient,
297
- { ...launchOpts, sessionId: storedSessionId },
305
+ { ...launchOpts, sessionId: storedSessionId, cleanSession: getCozempicAvailable() ? defaultCleanSession : undefined },
298
306
  )
299
307
  const elapsed = Date.now() - routeStart
300
308
  if (record !== null) {
package/src/tokens.ts CHANGED
@@ -7,11 +7,24 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  */
9
9
 
10
+ // ---------------------------------------------------------------------------
11
+ // Dry-run detection
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function isDryRun(): boolean {
15
+ const val = (process.env['SLACK_DRY_RUN'] ?? '').toLowerCase()
16
+ return val === '1' || val === 'true' || val === 'yes'
17
+ }
18
+
10
19
  // ---------------------------------------------------------------------------
11
20
  // Token loading
12
21
  // ---------------------------------------------------------------------------
13
22
 
14
23
  export function loadTokens(): { botToken: string; appToken: string } {
24
+ if (isDryRun()) {
25
+ return { botToken: 'xoxb-dry-run', appToken: 'xapp-dry-run' }
26
+ }
27
+
15
28
  const botToken = process.env['SLACK_BOT_TOKEN'] ?? ''
16
29
  const appToken = process.env['SLACK_APP_TOKEN'] ?? ''
17
30