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 +87 -5
- package/hooks/ask-relay.sh +19 -15
- package/hooks/permission-relay.sh +21 -16
- package/package.json +2 -2
- package/src/config.ts +70 -1
- package/src/cozempic.ts +18 -5
- package/src/message-archive.ts +267 -0
- package/src/peer-pid.ts +28 -9
- package/src/registry.ts +49 -11
- package/src/server.ts +143 -75
- package/src/session-manager.ts +35 -8
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
|
|
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**:
|
|
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
|
|
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
|
|
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
|
+
```
|
package/hooks/ask-relay.sh
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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) ||
|
|
31
|
-
OPTIONS=$(echo "$INPUT" | jq -c '.tool_input.options // []' 2>/dev/null) ||
|
|
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" = "[]" ]
|
|
35
|
-
|
|
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},\"
|
|
42
|
-
2>/dev/null) ||
|
|
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) ||
|
|
48
|
+
REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || deny_and_exit
|
|
45
49
|
if [ -z "$REQUEST_ID" ]; then
|
|
46
|
-
|
|
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) ||
|
|
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) ||
|
|
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) ||
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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) ||
|
|
34
|
-
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) ||
|
|
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
|
|
44
|
-
'{tool_name: $tool_name, tool_input: $tool_input,
|
|
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) ||
|
|
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) ||
|
|
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
|
-
|
|
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) ||
|
|
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) ||
|
|
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) ||
|
|
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 —
|
|
81
|
-
|
|
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.
|
|
4
|
-
"description": "Multi-session Slack-to-Claude bridge
|
|
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
|
-
|
|
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) {
|