claude-slack-channel-bots 0.5.0 → 0.5.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
@@ -105,7 +105,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
105
105
 
106
106
  | Field | Type | Default | Description |
107
107
  |---|---|---|---|
108
- | `routes` | object | required | Map of Slack channel ID → route entry. Each entry requires a `cwd` field: the working directory for that session. Used to identify sessions via `roots/list` after MCP handshake. `~` is expanded. Each `cwd` must be unique across all routes. |
108
+ | `routes` | object | required | Map of Slack channel ID → route entry. Each entry requires a `cwd` field: the working directory for that session. Used to identify sessions via `roots/list` after MCP handshake. `~` is expanded. Each `cwd` must be unique across all routes. May also include an optional `claude_config_dir` string (see below). |
109
109
  | `default_route` | string | — | CWD path to use when a message arrives on a channel with no explicit entry in `routes`. Must match an existing route `cwd`. Channels that are in `routes` but whose session is not yet registered have their messages dropped — they do not fall back to `default_route`. |
110
110
  | `default_dm_session` | string | — | CWD path of the session that handles direct messages. Must match an existing route `cwd`. |
111
111
  | `bind` | string | `"127.0.0.1"` | Interface the HTTP server binds to. Use `"0.0.0.0"` to expose on all interfaces. |
@@ -118,6 +118,30 @@ A skeleton file is created by postinstall. Populate it before running `start`.
118
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
119
  | `system_prompt_mode` | string | `"append"` | Controls how `append_system_prompt_file` is applied. `"append"`: the custom prompt file is appended on top of `CLAUDE.md` (default, current behavior). `"none"`: only `CLAUDE.md` is used; `append_system_prompt_file` is ignored even if set. Use `"none"` when the project's `CLAUDE.md` already contains everything the bot needs. |
120
120
  | `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
121
+ | `message_archive_db` | string | — | Path to a SQLite DB where every inbound Slack message is archived in real time. Parent directories are created if missing; schema is initialized on first open. Compatible with the `archive-messages.py` backfill script — both can write concurrently. Feature is disabled when absent. |
122
+ | `claude_config_dir` | string | — | Path to a Claude on-disk config directory. When set, managed sessions launch with `CLAUDE_CONFIG_DIR='<resolved-path>'` so the bot authenticates against a specific account. `~` is expanded and the path is resolved to absolute. Per-route `routes[id].claude_config_dir` overrides this top-level value for individual channels. When neither is set, Claude's own default applies. Must be non-empty when set. |
123
+ | `resume_enabled` | boolean | `true` | When `false`, the session manager always performs a fresh Claude session launch instead of resuming, both on startup and on runtime auto-restart, even when a stored session ID exists. Disabling this skips the `--resume` flag entirely. Use this as a workaround if your Claude Code version crashes with "sandbox required but unavailable" on `--resume` (a known regression in v2.1.120). |
124
+
125
+ #### Per-route `claude_config_dir` override
126
+
127
+ When you want different bot sessions to authenticate as different Claude accounts (e.g. one channel runs as a personal Max account, another as a corporate account), set `claude_config_dir` on the individual route. Per-route values take priority over the top-level `claude_config_dir`; routes without their own override fall back to the top-level value.
128
+
129
+ ```json
130
+ {
131
+ "routes": {
132
+ "C_PERSONAL": {
133
+ "cwd": "~/projects/alpha",
134
+ "claude_config_dir": "~/.claude-maxauth"
135
+ },
136
+ "C_CORPORATE": {
137
+ "cwd": "~/projects/beta"
138
+ }
139
+ },
140
+ "claude_config_dir": "~/.claude-corp"
141
+ }
142
+ ```
143
+
144
+ `C_PERSONAL` launches with the Max account; `C_CORPORATE` falls through to the top-level value and uses the corporate account. Use `claude auth login --claudeai` (or `--console`) with `CLAUDE_CONFIG_DIR` set to the same directory to populate each config dir before starting the server.
121
145
 
122
146
  ---
123
147
 
@@ -222,7 +246,7 @@ Gracefully exits all managed Claude Code sessions, then stops and starts the ser
222
246
  claude-slack-channel-bots clean_restart
223
247
  ```
224
248
 
225
- For each session in `sessions.json`, sends `/exit` to the tmux session and polls until Claude exits. All sessions are processed in parallel. If a session does not exit within `exit_timeout` seconds (default 120s), its tmux session is force-killed. Individual session errors are logged and do not abort the restart. After the server restarts, sessions are relaunched with `--resume` using the stored session IDs in `sessions.json`, preserving conversation context.
249
+ For each session in `sessions.json`, sends `/exit` to the tmux session and polls until Claude exits. All sessions are processed in parallel. If a session does not exit within `exit_timeout` seconds (default 120s), its tmux session is force-killed. Individual session errors are logged and do not abort the restart. After the server restarts, sessions are relaunched using the stored session IDs in `sessions.json`. When `resume_enabled` is `true` (the default), sessions resume with `--resume`, preserving conversation context. When `resume_enabled` is `false`, sessions always launch fresh without `--resume`.
226
250
 
227
251
  Behavior by case:
228
252
 
@@ -271,17 +295,65 @@ Each MCP endpoint exposes the following tools to the connected Claude Code sessi
271
295
 
272
296
  ---
273
297
 
298
+ ## Interject
299
+
300
+ POST to `/interject` to inject a message into an active Claude session from localhost. Only requests from `127.0.0.1` or `::1` are accepted — external callers are rejected with 403.
301
+
302
+ ### Request
303
+
304
+ ```sh
305
+ curl -X POST http://localhost:<port>/interject \
306
+ -H "Content-Type: application/json" \
307
+ -d '{"channel": "C1234567890", "message": "Hello from a script", "sender": "my-cron-job"}'
308
+ ```
309
+
310
+ | Field | Required | Description |
311
+ |---|---|---|
312
+ | `channel` | yes | Slack channel ID matching an entry in `config.json → routes`. |
313
+ | `message` | yes | Text to inject into the session. |
314
+ | `sender` | no | Label attached to the injected message. Defaults to `"interject"`. |
315
+
316
+ ### Response
317
+
318
+ On success, returns HTTP 200:
319
+
320
+ ```json
321
+ { "ok": true, "channel": "C1234567890", "cwd": "/path/to/session" }
322
+ ```
323
+
324
+ ### Error conditions
325
+
326
+ | Status | Meaning |
327
+ |---|---|
328
+ | 400 | Invalid JSON or missing required field (`channel` or `message`). |
329
+ | 403 | Request did not originate from localhost. |
330
+ | 404 | Channel not found in `config.json → routes`. |
331
+ | 405 | Must use POST method. |
332
+ | 413 | Request body exceeds 32KB. |
333
+ | 503 | Channel is routed but no active session is connected. |
334
+
335
+ ### Example: crontab reminder
336
+
337
+ ```sh
338
+ # crontab -e
339
+ 0 9 * * 1 curl -s -X POST http://localhost:3100/interject \
340
+ -H "Content-Type: application/json" \
341
+ -d '{"channel": "C1234567890", "message": "Weekly reminder: update the changelog before standup.", "sender": "cron"}'
342
+ ```
343
+
344
+ ---
345
+
274
346
  ## Permission Relay
275
347
 
276
348
  When Claude Code requires tool approval, the permission relay surfaces an interactive Slack message with **Allow** and **Deny** buttons instead of blocking the TUI. The Claude Code hook POSTs the pending request to the server, then long-polls for the user's response. Once the user clicks a button, the result is returned to Claude Code and execution continues.
277
349
 
278
350
  The `ask-relay.sh` hook intercepts `AskUserQuestion` tool calls via `PreToolUse`, posts the question and its options to Slack as interactive buttons, and waits for the user's selection. The answer is returned to Claude Code via `updatedInput` without blocking the TUI.
279
351
 
280
- Both hooks are **scope-guarded**: on each invocation they call the server's `GET /is-managed?pid=$PPID` endpoint to verify that the calling process belongs to a server-managed Claude session. If the server is not running or does not recognize the PID, the hooks exit silently (no-op). This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot.
352
+ Both hooks are **scope-guarded**: they check the `CLAUDE_MANAGED_CHANNEL` environment variable, which is set as an inline env var (no `export`) on the `claude` command at launch. If the variable is absent, the hooks exit silently (no-op). This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot. If the variable is present but the server is unreachable, hooks fail closed (deny the tool call) to prevent indefinite hangs in headless sessions.
281
353
 
282
354
  Both hooks use a **two-phase long-poll protocol**:
283
355
 
284
- 1. **Phase 1 — Create request:** The hook POSTs to `/permission` (or `/ask`) with the tool name, input, and CWD. The server posts an interactive Slack message and returns a `requestId`.
356
+ 1. **Phase 1 — Create request:** The hook POSTs to `/permission` (or `/ask`) with the tool name, input, and channel ID (from `$CLAUDE_MANAGED_CHANNEL`). The server posts an interactive Slack message and returns a `requestId`.
285
357
  2. **Phase 2 — Long-poll:** The hook GETs `/permission/{requestId}` (or `/ask/{requestId}`) in a loop with a 90-second `curl` timeout. The server holds the connection for up to 60 seconds waiting for a button click, then returns `{"status":"pending"}` if no decision has arrived. The hook retries immediately. Once the user clicks, the server returns `{"status":"decided","decision":"allow"|"deny"}` and the hook exits.
286
358
 
287
359
  ### Slack app prerequisites
@@ -355,10 +427,20 @@ After inviting the bot to a channel, Slack may not deliver messages until the bo
355
427
  Messages to channels not listed in `access.json → channels` and not present in `config.json → routes` are silently dropped. Use the `claude-slack-channels-config` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
356
428
 
357
429
  **Permission relay not working**
358
- Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `config.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks call `GET /is-managed?pid=$PPID` and exit silently if the server is unreachable or the PID is not recognized. Sessions launched manually will not trigger the relay.
430
+ Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `config.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks check the `CLAUDE_MANAGED_CHANNEL` env var and exit silently if it is not set. Sessions launched manually will not have this variable and will not trigger the relay.
359
431
 
360
432
  **Session not restarting after crash**
361
433
  After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `config.json`.
362
434
 
363
435
  **Session stuck during clean_restart**
364
436
  If a session does not exit within `exit_timeout` seconds (default 120s), `clean_restart` force-kills its tmux session and proceeds. To manually recover, run `tmux kill-session -t <session-name>` for any remaining sessions, then `claude-slack-channel-bots stop && claude-slack-channel-bots start`.
437
+
438
+ **Session crashes on resume with "sandbox required but unavailable"**
439
+ This is a known regression in certain Claude Code releases (e.g. v2.1.120) where `--resume` triggers a sandbox check that fails in headless environments. Set `resume_enabled: false` in `config.json` to disable `--resume` entirely — the bot will always start a fresh Claude session instead of resuming a prior conversation, both on startup and on runtime auto-restart:
440
+
441
+ ```json
442
+ {
443
+ "routes": { ... },
444
+ "resume_enabled": false
445
+ }
446
+ ```
@@ -13,11 +13,16 @@ fi
13
13
  # Read port from config.json
14
14
  PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json" 2>/dev/null) || PORT=3100
15
15
 
16
- # Guard: only relay for bot-managed sessions (server PID check)
17
- HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
18
- if [ "$HTTP_STATUS" != "200" ]; then
16
+ # Guard: only relay for bot-managed sessions
17
+ if [ -z "${CLAUDE_MANAGED_CHANNEL:-}" ]; then
19
18
  exit 0
20
19
  fi
20
+ CHANNEL="$CLAUDE_MANAGED_CHANNEL"
21
+
22
+ deny_and_exit() {
23
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}\n'
24
+ exit 0
25
+ }
21
26
 
22
27
  # Only handle AskUserQuestion
23
28
  INPUT=$(cat)
@@ -27,33 +32,32 @@ if [ "$TOOL_NAME" != "AskUserQuestion" ]; then
27
32
  fi
28
33
 
29
34
  # Extract question and options from tool input
30
- QUESTION=$(echo "$INPUT" | jq -r '.tool_input.question // ""' 2>/dev/null) || exit 0
31
- OPTIONS=$(echo "$INPUT" | jq -c '.tool_input.options // []' 2>/dev/null) || exit 0
32
- CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0
35
+ QUESTION=$(echo "$INPUT" | jq -r '.tool_input.question // ""' 2>/dev/null) || deny_and_exit
36
+ OPTIONS=$(echo "$INPUT" | jq -c '.tool_input.options // []' 2>/dev/null) || deny_and_exit
33
37
 
34
- if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ] || [ -z "$CWD" ]; then
35
- exit 0
38
+ if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ]; then
39
+ deny_and_exit
36
40
  fi
37
41
 
38
42
  # Phase 1: POST question to server
39
43
  RESPONSE=$(curl -s -f -X POST "http://127.0.0.1:${PORT}/ask" \
40
44
  -H 'Content-Type: application/json' \
41
- -d "{\"question\":$(printf '%s' "$QUESTION" | jq -Rs .),\"options\":${OPTIONS},\"cwd\":$(printf '%s' "$CWD" | jq -Rs .)}" \
42
- 2>/dev/null) || exit 0
45
+ -d "{\"question\":$(printf '%s' "$QUESTION" | jq -Rs .),\"options\":${OPTIONS},\"channel\":$(printf '%s' "$CHANNEL" | jq -Rs .)}" \
46
+ 2>/dev/null) || deny_and_exit
43
47
 
44
- REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || exit 0
48
+ REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || deny_and_exit
45
49
  if [ -z "$REQUEST_ID" ]; then
46
- exit 0
50
+ deny_and_exit
47
51
  fi
48
52
 
49
53
  # Phase 2: Long-poll for answer
50
54
  while true; do
51
- POLL_RESPONSE=$(curl -s -f --max-time 90 "http://127.0.0.1:${PORT}/ask/${REQUEST_ID}" 2>/dev/null) || exit 0
55
+ POLL_RESPONSE=$(curl -s -f --max-time 90 "http://127.0.0.1:${PORT}/ask/${REQUEST_ID}" 2>/dev/null) || deny_and_exit
52
56
 
53
- STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) || exit 0
57
+ STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) || deny_and_exit
54
58
 
55
59
  if [ "$STATUS" = "decided" ]; then
56
- ANSWER=$(echo "$POLL_RESPONSE" | jq -r '.answer // ""' 2>/dev/null) || exit 0
60
+ ANSWER=$(echo "$POLL_RESPONSE" | jq -r '.answer // ""' 2>/dev/null) || deny_and_exit
57
61
  # Build the answers object: { "question text": "selected answer" }
58
62
  ANSWERS_JSON=$(jq -n --arg q "$QUESTION" --arg a "$ANSWER" '{($q): $a}')
59
63
  # Allow the tool and provide the answer via updatedInput
@@ -20,19 +20,24 @@ if [ -f "$CONFIG_FILE" ]; then
20
20
  fi
21
21
  fi
22
22
 
23
- # Guard: only relay for bot-managed sessions (server PID check)
24
- HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
25
- if [ "$HTTP_STATUS" != "200" ]; then
23
+ # Guard: only relay for bot-managed sessions
24
+ if [ -z "${CLAUDE_MANAGED_CHANNEL:-}" ]; then
25
+ printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Not a managed Slack session"}}}\n'
26
26
  exit 0
27
27
  fi
28
+ CHANNEL="$CLAUDE_MANAGED_CHANNEL"
29
+
30
+ deny_and_exit() {
31
+ printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"%s"}}}\n' "$1"
32
+ exit 0
33
+ }
28
34
 
29
35
  # Read stdin
30
36
  INPUT=$(cat)
31
37
 
32
38
  # Extract fields from input
33
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
34
- TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || exit 0
35
- CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0
39
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || deny_and_exit "Failed to parse tool_name"
40
+ TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || deny_and_exit "Failed to parse tool_input"
36
41
 
37
42
  BASE_URL="http://127.0.0.1:${PORT}"
38
43
 
@@ -40,27 +45,27 @@ BASE_URL="http://127.0.0.1:${PORT}"
40
45
  PAYLOAD=$(jq -n \
41
46
  --arg tool_name "$TOOL_NAME" \
42
47
  --argjson tool_input "$TOOL_INPUT" \
43
- --arg cwd "$CWD" \
44
- '{tool_name: $tool_name, tool_input: $tool_input, cwd: $cwd}' 2>/dev/null) || exit 0
48
+ --arg channel "$CHANNEL" \
49
+ '{tool_name: $tool_name, tool_input: $tool_input, channel: $channel}' 2>/dev/null) || deny_and_exit "Failed to build payload"
45
50
 
46
51
  RESPONSE=$(curl -s -f -X POST \
47
52
  -H "Content-Type: application/json" \
48
53
  -d "$PAYLOAD" \
49
54
  --max-time 10 \
50
- "${BASE_URL}/permission" 2>/dev/null) || exit 0
55
+ "${BASE_URL}/permission" 2>/dev/null) || deny_and_exit "Server unreachable"
51
56
 
52
- REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || exit 0
57
+ REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || deny_and_exit "Failed to parse response"
53
58
  if [ -z "$REQUEST_ID" ]; then
54
- exit 0
59
+ deny_and_exit "No requestId in server response"
55
60
  fi
56
61
 
57
62
  # Phase 2 — Long-poll loop (curl --max-time 90: 60s server hold + 30s buffer)
58
63
  while true; do
59
64
  POLL_RESPONSE=$(curl -s -f \
60
65
  --max-time 90 \
61
- "${BASE_URL}/permission/${REQUEST_ID}" 2>/dev/null) || exit 0
66
+ "${BASE_URL}/permission/${REQUEST_ID}" 2>/dev/null) || deny_and_exit "Server connection lost"
62
67
 
63
- STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) || exit 0
68
+ STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) || deny_and_exit "Failed to parse response"
64
69
 
65
70
  case "$STATUS" in
66
71
  "pending")
@@ -68,7 +73,7 @@ while true; do
68
73
  continue
69
74
  ;;
70
75
  "decided")
71
- BEHAVIOR=$(echo "$POLL_RESPONSE" | jq -r '.decision // ""' 2>/dev/null) || exit 0
76
+ BEHAVIOR=$(echo "$POLL_RESPONSE" | jq -r '.decision // ""' 2>/dev/null) || deny_and_exit "Failed to parse decision"
72
77
  if [ "$BEHAVIOR" = "allow" ]; then
73
78
  printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}\n'
74
79
  else
@@ -77,8 +82,8 @@ while true; do
77
82
  exit 0
78
83
  ;;
79
84
  *)
80
- # Unknown status — fall through to TUI
81
- exit 0
85
+ # Unknown status — deny to fail closed
86
+ deny_and_exit "Unexpected poll status: $STATUS"
82
87
  ;;
83
88
  esac
84
89
  done
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.5.0",
4
- "description": "Multi-session Slack-to-Claude bridge \u2014 run multiple Claude Code bots across Slack channels via Socket Mode",
3
+ "version": "0.5.1",
4
+ "description": "Multi-session Slack-to-Claude bridge 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
@@ -26,6 +26,15 @@ export const ALLOWED_SYSTEM_PROMPT_MODES = ['append', 'none']
26
26
 
27
27
  export interface RouteEntry {
28
28
  cwd: string
29
+ /**
30
+ * Optional path to a Claude on-disk config directory for this route.
31
+ * When set, sessions for this route launch with `CLAUDE_CONFIG_DIR=<path>`,
32
+ * letting different routes authenticate against different Claude accounts.
33
+ * `~` is expanded and the path is resolved to absolute. When omitted, the
34
+ * top-level `claude_config_dir` is used (and Claude's own default applies
35
+ * if neither is set).
36
+ */
37
+ claude_config_dir?: string
29
38
  }
30
39
 
31
40
  /** Raw shape of config.json as parsed from disk. All optional fields may be absent. */
@@ -45,6 +54,21 @@ export interface RoutingConfigInput {
45
54
  append_system_prompt_file?: string
46
55
  cozempic_prescription?: string
47
56
  system_prompt_mode?: string
57
+ /** Optional path to a SQLite DB where every inbound Slack message will be archived. */
58
+ message_archive_db?: string
59
+ /**
60
+ * Top-level Claude on-disk config directory for routes that do not specify
61
+ * their own `claude_config_dir`. When set, managed sessions launch with
62
+ * `CLAUDE_CONFIG_DIR=<path>`. When omitted, Claude's own default applies.
63
+ * `~` is expanded and the path is resolved to absolute.
64
+ */
65
+ claude_config_dir?: string
66
+ /**
67
+ * When false, disables --resume on startup so bots always launch fresh.
68
+ * Defaults to true. Set to false to work around `--resume` regressions
69
+ * (e.g. Claude Code v2.1.120 "sandbox required but unavailable").
70
+ */
71
+ resume_enabled?: boolean
48
72
  }
49
73
 
50
74
  /** Validated, fully-resolved routing configuration with all defaults applied. */
@@ -62,6 +86,11 @@ export interface RoutingConfig {
62
86
  append_system_prompt_file?: string
63
87
  cozempic_prescription: string
64
88
  system_prompt_mode: string
89
+ /** Absolute path to SQLite archive DB. Undefined disables the feature. */
90
+ message_archive_db?: string
91
+ claude_config_dir?: string
92
+ /** When false, --resume is skipped on startup and bots always launch fresh. Defaults to true. */
93
+ resume_enabled: boolean
65
94
  }
66
95
 
67
96
  // ---------------------------------------------------------------------------
@@ -87,6 +116,9 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
87
116
  append_system_prompt_file: input.append_system_prompt_file,
88
117
  cozempic_prescription: input.cozempic_prescription ?? 'standard',
89
118
  system_prompt_mode: input.system_prompt_mode ?? 'append',
119
+ message_archive_db: input.message_archive_db,
120
+ claude_config_dir: input.claude_config_dir,
121
+ resume_enabled: input.resume_enabled ?? true,
90
122
  }
91
123
  }
92
124
 
@@ -182,6 +214,26 @@ export function validateConfig(config: RoutingConfig): void {
182
214
  )
183
215
  }
184
216
  }
217
+
218
+ // Top-level claude_config_dir, when set, must be a non-empty (post-trim) string
219
+ if (config.claude_config_dir !== undefined) {
220
+ if (typeof config.claude_config_dir !== 'string' || config.claude_config_dir.trim() === '') {
221
+ throw new Error(
222
+ 'Routing config validation error: claude_config_dir must be a non-empty string when set.',
223
+ )
224
+ }
225
+ }
226
+
227
+ // Per-route claude_config_dir, when set, must also be a non-empty (post-trim) string
228
+ for (const [channelId, route] of Object.entries(config.routes)) {
229
+ if (route.claude_config_dir !== undefined) {
230
+ if (typeof route.claude_config_dir !== 'string' || route.claude_config_dir.trim() === '') {
231
+ throw new Error(
232
+ `Routing config validation error: routes["${channelId}"].claude_config_dir must be a non-empty string when set.`,
233
+ )
234
+ }
235
+ }
236
+ }
185
237
  }
186
238
 
187
239
  /**
@@ -191,11 +243,20 @@ export function validateConfig(config: RoutingConfig): void {
191
243
  export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
192
244
  const withDefaults = applyDefaults(input)
193
245
 
194
- // Expand tildes on every route's cwd
246
+ // Expand tildes on every route's cwd and claude_config_dir; preserve other fields verbatim.
247
+ // Empty/whitespace claude_config_dir is preserved unchanged so validateConfig can reject it.
195
248
  const expandedRoutes: Record<string, RouteEntry> = {}
196
249
  for (const [channelId, entry] of Object.entries(withDefaults.routes)) {
197
250
  expandedRoutes[channelId] = {
251
+ ...entry,
198
252
  cwd: resolve(expandTilde(entry.cwd)),
253
+ ...(entry.claude_config_dir !== undefined
254
+ ? {
255
+ claude_config_dir: entry.claude_config_dir.trim() === ''
256
+ ? entry.claude_config_dir
257
+ : resolve(expandTilde(entry.claude_config_dir)),
258
+ }
259
+ : {}),
199
260
  }
200
261
  }
201
262
 
@@ -214,6 +275,14 @@ export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
214
275
  append_system_prompt_file: withDefaults.append_system_prompt_file !== undefined
215
276
  ? resolve(expandTilde(withDefaults.append_system_prompt_file))
216
277
  : undefined,
278
+ message_archive_db: withDefaults.message_archive_db !== undefined
279
+ ? resolve(expandTilde(withDefaults.message_archive_db))
280
+ : undefined,
281
+ claude_config_dir: withDefaults.claude_config_dir !== undefined
282
+ ? (withDefaults.claude_config_dir.trim() === ''
283
+ ? withDefaults.claude_config_dir
284
+ : resolve(expandTilde(withDefaults.claude_config_dir)))
285
+ : undefined,
217
286
  }
218
287
 
219
288
  validateConfig(config)
package/src/cozempic.ts CHANGED
@@ -63,10 +63,17 @@ export function _resetCozempicAvailable(): void {
63
63
  /**
64
64
  * Builds the absolute JSONL path for a given cwd and session ID.
65
65
  * Pure function — no I/O, no validation.
66
+ *
67
+ * When `configDir` is provided, the path is rooted at `${configDir}/projects/...`.
68
+ * This is used by managed bots launched with a custom `CLAUDE_CONFIG_DIR`,
69
+ * since Claude writes JSONL transcripts under that directory rather than
70
+ * `${homedir()}/.claude`. When omitted, falls back to the default
71
+ * `${homedir()}/.claude/projects/...` location.
66
72
  */
67
- export function resolveJsonlPath(cwd: string, sessionId: string): string {
73
+ export function resolveJsonlPath(cwd: string, sessionId: string, configDir?: string): string {
68
74
  const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-')
69
- return `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
75
+ const root = configDir ?? `${homedir()}/.claude`
76
+ return `${root}/projects/${slug}/${sessionId}.jsonl`
70
77
  }
71
78
 
72
79
  // ---------------------------------------------------------------------------
@@ -89,7 +96,7 @@ export function readFileSizeBytes(path: string): number | null {
89
96
  // Session cleaning
90
97
  // ---------------------------------------------------------------------------
91
98
 
92
- export type CleanSessionFn = (sessionId: string, cwd: string, prescription: string) => Promise<void>
99
+ export type CleanSessionFn = (sessionId: string, cwd: string, prescription: string, configDir?: string) => Promise<void>
93
100
 
94
101
  /**
95
102
  * Runs `cozempic treat <sessionId> -rx <prescription> --execute` against the
@@ -97,13 +104,19 @@ export type CleanSessionFn = (sessionId: string, cwd: string, prescription: stri
97
104
  *
98
105
  * Skips if the JSONL is missing or empty. Streams cozempic output to stderr
99
106
  * with the `[slack] cozempic:` prefix and logs before/after file sizes.
107
+ *
108
+ * When `configDir` is provided, resolves the JSONL path under
109
+ * `${configDir}/projects/...` rather than the default `${homedir()}/.claude/...`.
110
+ * This is required for managed bots launched with a custom `CLAUDE_CONFIG_DIR`,
111
+ * since Claude writes its transcripts under that directory. Without it,
112
+ * cleaning before a `--resume` silently no-ops because the JSONL lookup misses.
100
113
  */
101
- export async function cleanSession(sessionId: string, cwd: string, prescription: string): Promise<void> {
114
+ export async function cleanSession(sessionId: string, cwd: string, prescription: string, configDir?: string): Promise<void> {
102
115
  if (!ALLOWED_PRESCRIPTIONS.includes(prescription)) {
103
116
  console.error(`[slack] cozempic: invalid prescription "${prescription}" — skipping clean session=${sessionId}`)
104
117
  return
105
118
  }
106
- const path = resolveJsonlPath(cwd, sessionId)
119
+ const path = resolveJsonlPath(cwd, sessionId, configDir)
107
120
  const beforeSize = readFileSizeBytes(path)
108
121
 
109
122
  if (beforeSize === null) {