claude-slack-channel-bots 0.4.2 → 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 +103 -20
- package/hooks/ask-relay.sh +29 -16
- package/hooks/permission-relay.sh +33 -27
- package/package.json +2 -2
- package/skills/claude-slack-channels-config/SKILL.md +3 -3
- package/skills/setup-slack-channel-bots/SKILL.md +23 -10
- package/src/cli.ts +3 -3
- package/src/config.ts +84 -4
- package/src/cozempic.ts +18 -5
- package/src/lib.ts +2 -2
- package/src/message-archive.ts +267 -0
- package/src/peer-pid.ts +28 -9
- package/src/postinstall.ts +16 -8
- package/src/registry.ts +49 -11
- package/src/server.ts +161 -51
- package/src/session-manager.ts +53 -9
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
|
|
|
56
56
|
|---|---|
|
|
57
57
|
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-…`). Required. Granted by the OAuth install flow. |
|
|
58
58
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
|
|
59
|
-
| `SLACK_STATE_DIR` | Override the directory where `
|
|
59
|
+
| `SLACK_STATE_DIR` | Override the directory where `config.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
|
|
60
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
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. |
|
|
62
62
|
|
|
@@ -74,9 +74,9 @@ export SLACK_DRY_RUN=1
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
-
### Routing (
|
|
77
|
+
### Routing (config.json)
|
|
78
78
|
|
|
79
|
-
`
|
|
79
|
+
`config.json` is read from `~/.claude/channels/slack/config.json` by default. Override the directory with `SLACK_STATE_DIR`.
|
|
80
80
|
|
|
81
81
|
A skeleton file is created by postinstall. Populate it before running `start`.
|
|
82
82
|
|
|
@@ -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. |
|
|
@@ -116,15 +116,40 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
116
116
|
| `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
|
|
117
117
|
| `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
|
|
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
|
+
| `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. |
|
|
119
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.
|
|
120
145
|
|
|
121
146
|
---
|
|
122
147
|
|
|
123
148
|
### Access Control (access.json)
|
|
124
149
|
|
|
125
|
-
`access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `
|
|
150
|
+
`access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `config.json`). A skeleton file with defaults is created by postinstall. The file is written with `0600` permissions.
|
|
126
151
|
|
|
127
|
-
Channels in `
|
|
152
|
+
Channels in `config.json` are automatically allowed — you do not need to list them here. The `channels` map is only needed for per-channel overrides like requiring @mentions or restricting which users can trigger the bot.
|
|
128
153
|
|
|
129
154
|
The `slack-channel-access` skill manages pairings and allowlist entries at runtime.
|
|
130
155
|
|
|
@@ -153,7 +178,7 @@ The `slack-channel-access` skill manages pairings and allowlist entries at runti
|
|
|
153
178
|
|---|---|---|---|
|
|
154
179
|
| `dmPolicy` | `"pairing"` \| `"allowlist"` \| `"disabled"` | `"pairing"` | Controls who can DM the bot. `pairing`: unknown users receive a one-time code and are added to `allowFrom` after verification. `allowlist`: only users in `allowFrom` are accepted. `disabled`: all DMs are dropped. |
|
|
155
180
|
| `allowFrom` | string[] | `[]` | Slack user IDs allowed to DM the bot unconditionally (regardless of `dmPolicy`). |
|
|
156
|
-
| `channels` | object | `{}` | Optional per-channel overrides. Channels in `
|
|
181
|
+
| `channels` | object | `{}` | Optional per-channel overrides. Channels in `config.json` are allowed automatically — only add entries here to customize behavior (e.g. require @mention or restrict users). Each entry is a `ChannelPolicy`. |
|
|
157
182
|
| `channels[id].requireMention` | boolean | `false` | When `true`, messages in that channel are only delivered if the bot is `@mentioned`. |
|
|
158
183
|
| `channels[id].allowFrom` | string[] | `[]` | When non-empty, restricts delivery to the listed Slack user IDs for that channel. |
|
|
159
184
|
| `pending` | object | `{}` | Managed by the server. Stores in-flight pairing codes indexed by code string. Do not edit manually. |
|
|
@@ -178,7 +203,7 @@ Claude Code sessions need a config file pointing at the MCP server. A skeleton i
|
|
|
178
203
|
}
|
|
179
204
|
```
|
|
180
205
|
|
|
181
|
-
If you changed `port` or `bind` in `
|
|
206
|
+
If you changed `port` or `bind` in `config.json`, update the `url` here to match. The server-managed session launcher uses `mcp_config_path` from `config.json` to locate this file.
|
|
182
207
|
|
|
183
208
|
---
|
|
184
209
|
|
|
@@ -195,7 +220,7 @@ Checks prerequisites, then daemonizes the server.
|
|
|
195
220
|
1. `tmux` is on `PATH` — fails with `missing prerequisite: tmux` if not found.
|
|
196
221
|
2. `SLACK_BOT_TOKEN` is set — fails with `missing prerequisite: SLACK_BOT_TOKEN environment variable` if absent.
|
|
197
222
|
3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
|
|
198
|
-
4. `
|
|
223
|
+
4. `config.json` exists at `STATE_DIR/config.json` — fails with the full path if not found.
|
|
199
224
|
|
|
200
225
|
If all checks pass, the parent process spawns a detached child process and exits immediately, printing the child PID. The child starts the server and writes its PID to `STATE_DIR/server.pid`. Conversation context is preserved across server restarts when possible.
|
|
201
226
|
|
|
@@ -221,7 +246,7 @@ Gracefully exits all managed Claude Code sessions, then stops and starts the ser
|
|
|
221
246
|
claude-slack-channel-bots clean_restart
|
|
222
247
|
```
|
|
223
248
|
|
|
224
|
-
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`.
|
|
225
250
|
|
|
226
251
|
Behavior by case:
|
|
227
252
|
|
|
@@ -270,17 +295,65 @@ Each MCP endpoint exposes the following tools to the connected Claude Code sessi
|
|
|
270
295
|
|
|
271
296
|
---
|
|
272
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
|
+
|
|
273
346
|
## Permission Relay
|
|
274
347
|
|
|
275
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.
|
|
276
349
|
|
|
277
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.
|
|
278
351
|
|
|
279
|
-
Both hooks are **scope-guarded**: they check
|
|
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.
|
|
280
353
|
|
|
281
354
|
Both hooks use a **two-phase long-poll protocol**:
|
|
282
355
|
|
|
283
|
-
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`.
|
|
284
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.
|
|
285
358
|
|
|
286
359
|
### Slack app prerequisites
|
|
@@ -328,7 +401,7 @@ To enable it: open your Slack app config → **Interactivity & Shortcuts** → t
|
|
|
328
401
|
|
|
329
402
|
`permission-relay.sh` relays tool permission requests (Allow/Deny) to Slack via `PermissionRequest`. `ask-relay.sh` relays `AskUserQuestion` calls to Slack via `PreToolUse`, returning the user's selection without blocking the TUI.
|
|
330
403
|
|
|
331
|
-
Both hooks auto-detect the server port from `
|
|
404
|
+
Both hooks auto-detect the server port from `config.json`. They read `${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json` and use the `port` field (defaulting to `3100`), so they stay in sync if you change the port in routing config.
|
|
332
405
|
|
|
333
406
|
### Setup skill
|
|
334
407
|
|
|
@@ -341,23 +414,33 @@ The `update-config` skill can automate hook installation. It copies or symlinks
|
|
|
341
414
|
**Missing environment variables**
|
|
342
415
|
`start` exits with `missing prerequisite: SLACK_BOT_TOKEN environment variable` or `SLACK_APP_TOKEN environment variable`. Export both tokens in your shell profile and open a new terminal before running `start`.
|
|
343
416
|
|
|
344
|
-
**
|
|
345
|
-
`start` exits with `missing prerequisite:
|
|
417
|
+
**config.json not found**
|
|
418
|
+
`start` exits with `missing prerequisite: config.json not found at <path>`. Run `bun postinstall.ts` to create a skeleton, or create the file manually. Verify `SLACK_STATE_DIR` matches the directory you populated.
|
|
346
419
|
|
|
347
|
-
**
|
|
348
|
-
If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `
|
|
420
|
+
**config.json CWD mismatch**
|
|
421
|
+
If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `config.json`. Confirm the session's working directory matches the entry exactly (after tilde expansion). Duplicate CWDs across multiple routes are rejected at startup.
|
|
349
422
|
|
|
350
423
|
**Bot not receiving messages in a new channel**
|
|
351
424
|
After inviting the bot to a channel, Slack may not deliver messages until the bot is @mentioned for the first time. This is a Slack Socket Mode behavior — the first @mention activates event delivery for that channel. After that, all messages flow normally regardless of `requireMention` settings.
|
|
352
425
|
|
|
353
426
|
**Channel not in access.json**
|
|
354
|
-
Messages to channels not listed in `access.json → channels` and not present in `
|
|
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.
|
|
355
428
|
|
|
356
429
|
**Permission relay not working**
|
|
357
|
-
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 `
|
|
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.
|
|
358
431
|
|
|
359
432
|
**Session not restarting after crash**
|
|
360
|
-
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 `
|
|
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`.
|
|
361
434
|
|
|
362
435
|
**Session stuck during clean_restart**
|
|
363
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
|
@@ -2,10 +2,27 @@
|
|
|
2
2
|
# AskUserQuestion relay — intercepts via PreToolUse, posts to Slack, returns answer
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
|
|
5
|
+
# Check dependencies (must be before guard since guard needs curl+jq)
|
|
6
|
+
if ! command -v jq &>/dev/null; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
if ! command -v curl &>/dev/null; then
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
# Read port from config.json
|
|
14
|
+
PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json" 2>/dev/null) || PORT=3100
|
|
15
|
+
|
|
5
16
|
# Guard: only relay for bot-managed sessions
|
|
6
|
-
if [ -z "${
|
|
17
|
+
if [ -z "${CLAUDE_MANAGED_CHANNEL:-}" ]; then
|
|
7
18
|
exit 0
|
|
8
19
|
fi
|
|
20
|
+
CHANNEL="$CLAUDE_MANAGED_CHANNEL"
|
|
21
|
+
|
|
22
|
+
deny_and_exit() {
|
|
23
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}\n'
|
|
24
|
+
exit 0
|
|
25
|
+
}
|
|
9
26
|
|
|
10
27
|
# Only handle AskUserQuestion
|
|
11
28
|
INPUT=$(cat)
|
|
@@ -15,36 +32,32 @@ if [ "$TOOL_NAME" != "AskUserQuestion" ]; then
|
|
|
15
32
|
fi
|
|
16
33
|
|
|
17
34
|
# Extract question and options from tool input
|
|
18
|
-
QUESTION=$(echo "$INPUT" | jq -r '.tool_input.question // ""' 2>/dev/null) ||
|
|
19
|
-
OPTIONS=$(echo "$INPUT" | jq -c '.tool_input.options // []' 2>/dev/null) ||
|
|
20
|
-
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
|
|
21
37
|
|
|
22
|
-
if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ]
|
|
23
|
-
|
|
38
|
+
if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ]; then
|
|
39
|
+
deny_and_exit
|
|
24
40
|
fi
|
|
25
41
|
|
|
26
|
-
# Read port from routing config
|
|
27
|
-
PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json" 2>/dev/null) || PORT=3100
|
|
28
|
-
|
|
29
42
|
# Phase 1: POST question to server
|
|
30
43
|
RESPONSE=$(curl -s -f -X POST "http://127.0.0.1:${PORT}/ask" \
|
|
31
44
|
-H 'Content-Type: application/json' \
|
|
32
|
-
-d "{\"question\":$(printf '%s' "$QUESTION" | jq -Rs .),\"options\":${OPTIONS},\"
|
|
33
|
-
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
|
|
34
47
|
|
|
35
|
-
REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) ||
|
|
48
|
+
REQUEST_ID=$(echo "$RESPONSE" | jq -r '.requestId // ""' 2>/dev/null) || deny_and_exit
|
|
36
49
|
if [ -z "$REQUEST_ID" ]; then
|
|
37
|
-
|
|
50
|
+
deny_and_exit
|
|
38
51
|
fi
|
|
39
52
|
|
|
40
53
|
# Phase 2: Long-poll for answer
|
|
41
54
|
while true; do
|
|
42
|
-
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
|
|
43
56
|
|
|
44
|
-
STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) ||
|
|
57
|
+
STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status // ""' 2>/dev/null) || deny_and_exit
|
|
45
58
|
|
|
46
59
|
if [ "$STATUS" = "decided" ]; then
|
|
47
|
-
ANSWER=$(echo "$POLL_RESPONSE" | jq -r '.answer // ""' 2>/dev/null) ||
|
|
60
|
+
ANSWER=$(echo "$POLL_RESPONSE" | jq -r '.answer // ""' 2>/dev/null) || deny_and_exit
|
|
48
61
|
# Build the answers object: { "question text": "selected answer" }
|
|
49
62
|
ANSWERS_JSON=$(jq -n --arg q "$QUESTION" --arg a "$ANSWER" '{($q): $a}')
|
|
50
63
|
# Allow the tool and provide the answer via updatedInput
|
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
# permission-relay.sh - Claude Code PermissionRequest hook
|
|
3
3
|
# Implements two-phase long-poll to relay permission decisions via Slack channel server
|
|
4
4
|
|
|
5
|
-
# Guard: only relay for bot-managed sessions
|
|
6
|
-
if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
|
|
7
|
-
exit 0
|
|
8
|
-
fi
|
|
9
|
-
|
|
10
5
|
# Check dependencies
|
|
11
6
|
if ! command -v jq &>/dev/null; then
|
|
12
7
|
exit 0
|
|
@@ -15,51 +10,62 @@ if ! command -v curl &>/dev/null; then
|
|
|
15
10
|
exit 0
|
|
16
11
|
fi
|
|
17
12
|
|
|
18
|
-
# Read
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# Extract fields from input
|
|
22
|
-
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
|
|
23
|
-
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || exit 0
|
|
24
|
-
CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0
|
|
25
|
-
|
|
26
|
-
# Read port from routing.json, default to 3100
|
|
27
|
-
ROUTING_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json"
|
|
13
|
+
# Read port from config.json, default to 3100
|
|
14
|
+
CONFIG_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json"
|
|
28
15
|
PORT=3100
|
|
29
|
-
if [ -f "$
|
|
30
|
-
ROUTED_PORT=$(jq -r '.port // empty' "$
|
|
16
|
+
if [ -f "$CONFIG_FILE" ]; then
|
|
17
|
+
ROUTED_PORT=$(jq -r '.port // empty' "$CONFIG_FILE" 2>/dev/null) || true
|
|
31
18
|
if [ -n "${ROUTED_PORT:-}" ]; then
|
|
32
19
|
PORT="$ROUTED_PORT"
|
|
33
20
|
fi
|
|
34
21
|
fi
|
|
35
22
|
|
|
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
|
+
exit 0
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
# Read stdin
|
|
36
|
+
INPUT=$(cat)
|
|
37
|
+
|
|
38
|
+
# Extract fields from input
|
|
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"
|
|
41
|
+
|
|
36
42
|
BASE_URL="http://127.0.0.1:${PORT}"
|
|
37
43
|
|
|
38
44
|
# Phase 1 — Create permission request
|
|
39
45
|
PAYLOAD=$(jq -n \
|
|
40
46
|
--arg tool_name "$TOOL_NAME" \
|
|
41
47
|
--argjson tool_input "$TOOL_INPUT" \
|
|
42
|
-
--arg
|
|
43
|
-
'{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"
|
|
44
50
|
|
|
45
51
|
RESPONSE=$(curl -s -f -X POST \
|
|
46
52
|
-H "Content-Type: application/json" \
|
|
47
53
|
-d "$PAYLOAD" \
|
|
48
54
|
--max-time 10 \
|
|
49
|
-
"${BASE_URL}/permission" 2>/dev/null) ||
|
|
55
|
+
"${BASE_URL}/permission" 2>/dev/null) || deny_and_exit "Server unreachable"
|
|
50
56
|
|
|
51
|
-
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"
|
|
52
58
|
if [ -z "$REQUEST_ID" ]; then
|
|
53
|
-
|
|
59
|
+
deny_and_exit "No requestId in server response"
|
|
54
60
|
fi
|
|
55
61
|
|
|
56
62
|
# Phase 2 — Long-poll loop (curl --max-time 90: 60s server hold + 30s buffer)
|
|
57
63
|
while true; do
|
|
58
64
|
POLL_RESPONSE=$(curl -s -f \
|
|
59
65
|
--max-time 90 \
|
|
60
|
-
"${BASE_URL}/permission/${REQUEST_ID}" 2>/dev/null) ||
|
|
66
|
+
"${BASE_URL}/permission/${REQUEST_ID}" 2>/dev/null) || deny_and_exit "Server connection lost"
|
|
61
67
|
|
|
62
|
-
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"
|
|
63
69
|
|
|
64
70
|
case "$STATUS" in
|
|
65
71
|
"pending")
|
|
@@ -67,7 +73,7 @@ while true; do
|
|
|
67
73
|
continue
|
|
68
74
|
;;
|
|
69
75
|
"decided")
|
|
70
|
-
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"
|
|
71
77
|
if [ "$BEHAVIOR" = "allow" ]; then
|
|
72
78
|
printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}\n'
|
|
73
79
|
else
|
|
@@ -76,8 +82,8 @@ while true; do
|
|
|
76
82
|
exit 0
|
|
77
83
|
;;
|
|
78
84
|
*)
|
|
79
|
-
# Unknown status —
|
|
80
|
-
|
|
85
|
+
# Unknown status — deny to fail closed
|
|
86
|
+
deny_and_exit "Unexpected poll status: $STATUS"
|
|
81
87
|
;;
|
|
82
88
|
esac
|
|
83
89
|
done
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slack-channel-bots",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Multi-session Slack-to-Claude bridge
|
|
3
|
+
"version": "0.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"
|
|
@@ -33,7 +33,7 @@ message handling (ack reactions, chunking).
|
|
|
33
33
|
```bash
|
|
34
34
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
35
35
|
# access.json lives at $STATE_DIR/access.json
|
|
36
|
-
#
|
|
36
|
+
# config.json lives at $STATE_DIR/config.json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Always resolve the state directory using `$SLACK_STATE_DIR` with fallback to
|
|
@@ -102,12 +102,12 @@ Parse `$ARGUMENTS` and execute the matching subcommand:
|
|
|
102
102
|
|
|
103
103
|
### `status`
|
|
104
104
|
1. Load `access.json`
|
|
105
|
-
2. Load `
|
|
105
|
+
2. Load `config.json` from the same state directory
|
|
106
106
|
3. Display:
|
|
107
107
|
- DM policy
|
|
108
108
|
- Allowlisted user IDs
|
|
109
109
|
- Opted-in channels with their policies, showing two categories:
|
|
110
|
-
- **Implicit** — channels present in `
|
|
110
|
+
- **Implicit** — channels present in `config.json` routes (automatically opted-in)
|
|
111
111
|
- **Explicit** — channels configured in `access.json` channels (with their `requireMention` and `allowFrom` settings)
|
|
112
112
|
- Pending pairings (code + sender ID + expiry)
|
|
113
113
|
- Ack reaction setting (or "not set")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup-slack-channel-bots
|
|
3
|
-
description: Interactive setup wizard for claude-slack-channel-bots — checks tokens,
|
|
3
|
+
description: Interactive setup wizard for claude-slack-channel-bots — checks tokens, config.json, access.json, hooks, and Claude Code settings
|
|
4
4
|
version: 1.0.0
|
|
5
5
|
author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
6
6
|
license: MIT
|
|
@@ -122,14 +122,14 @@ want confirmation.
|
|
|
122
122
|
|
|
123
123
|
---
|
|
124
124
|
|
|
125
|
-
### Step 4 — Check
|
|
125
|
+
### Step 4 — Check config.json
|
|
126
126
|
|
|
127
127
|
State directory defaults to `~/.claude/channels/slack/`. Respect
|
|
128
128
|
`$SLACK_STATE_DIR` if set.
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
131
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
132
|
-
cat "$STATE_DIR/
|
|
132
|
+
cat "$STATE_DIR/config.json" 2>/dev/null || echo "NOT_FOUND"
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
**If the file does not exist:**
|
|
@@ -169,7 +169,7 @@ test -d "<expanded-path>" && echo "ok" || echo "not found"
|
|
|
169
169
|
Expand `~` before checking. If the directory does not exist, warn the user and
|
|
170
170
|
ask whether to proceed anyway or provide a different path.
|
|
171
171
|
|
|
172
|
-
After collecting at least one route, write the updated `
|
|
172
|
+
After collecting at least one route, write the updated `config.json`.
|
|
173
173
|
|
|
174
174
|
**Important:** Remind the user to invite the bot to each channel they configured.
|
|
175
175
|
In Slack, type `/invite @Claude Slack Channel Bots` in each channel (or whatever
|
|
@@ -210,7 +210,7 @@ either value:
|
|
|
210
210
|
|
|
211
211
|
**Validation before writing:**
|
|
212
212
|
|
|
213
|
-
Before writing `
|
|
213
|
+
Before writing `config.json`, verify that any value supplied for
|
|
214
214
|
`default_route` or `default_dm_session` exactly matches one of the `cwd`
|
|
215
215
|
values in `routes`. If a value does not match:
|
|
216
216
|
|
|
@@ -221,7 +221,7 @@ values in `routes`. If a value does not match:
|
|
|
221
221
|
|
|
222
222
|
Only write the field once a valid value is confirmed.
|
|
223
223
|
|
|
224
|
-
Write the final `
|
|
224
|
+
Write the final `config.json` with only the fields the user explicitly set
|
|
225
225
|
(plus the required `routes`). Do not write optional fields the user left at
|
|
226
226
|
their defaults unless asked.
|
|
227
227
|
|
|
@@ -230,7 +230,7 @@ their defaults unless asked.
|
|
|
230
230
|
### Step 5 — Configure custom system prompt for worker sessions
|
|
231
231
|
|
|
232
232
|
Worker sessions launched by the server can receive a custom system prompt
|
|
233
|
-
via `append_system_prompt_file` in `
|
|
233
|
+
via `append_system_prompt_file` in `config.json`. This controls how the
|
|
234
234
|
bots behave — their role, communication style, and capabilities.
|
|
235
235
|
|
|
236
236
|
**This is important.** Without a system prompt, bots won't know to communicate
|
|
@@ -275,7 +275,7 @@ Examples to offer if they're unsure:
|
|
|
275
275
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
276
276
|
```
|
|
277
277
|
|
|
278
|
-
3. Read `
|
|
278
|
+
3. Read `config.json`, add or update:
|
|
279
279
|
```json
|
|
280
280
|
"append_system_prompt_file": "~/.claude/channels/slack/system-prompt.md"
|
|
281
281
|
```
|
|
@@ -286,9 +286,22 @@ Examples to offer if they're unsure:
|
|
|
286
286
|
|
|
287
287
|
**If the user explicitly skips:**
|
|
288
288
|
|
|
289
|
-
Do not write `append_system_prompt_file` to `
|
|
289
|
+
Do not write `append_system_prompt_file` to `config.json`, but warn them
|
|
290
290
|
that bots won't know to communicate via Slack without a system prompt.
|
|
291
291
|
|
|
292
|
+
If the project's `CLAUDE.md` already contains all the instructions the bot
|
|
293
|
+
needs (including Slack communication directives), the user can opt out of a
|
|
294
|
+
separate prompt file by setting `system_prompt_mode: "none"` in `config.json`:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
"system_prompt_mode": "none"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
With this setting, `append_system_prompt_file` is ignored even if present, and
|
|
301
|
+
only `CLAUDE.md` is used. This is the recommended approach when the bot's
|
|
302
|
+
behavior is fully defined in the project's `CLAUDE.md` and a separate
|
|
303
|
+
`system-prompt.md` would be redundant.
|
|
304
|
+
|
|
292
305
|
---
|
|
293
306
|
|
|
294
307
|
### Step 6 — Check access.json
|
|
@@ -437,7 +450,7 @@ Print a final summary of what was checked and configured:
|
|
|
437
450
|
|
|
438
451
|
- Environment variables: set / missing
|
|
439
452
|
- Token format: valid / invalid
|
|
440
|
-
-
|
|
453
|
+
- config.json: populated (N routes) / skeleton
|
|
441
454
|
- append_system_prompt_file: configured / skipped
|
|
442
455
|
- access.json: present / missing
|
|
443
456
|
- permission-relay.sh hook: present and executable / missing
|
package/src/cli.ts
CHANGED
|
@@ -105,11 +105,11 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Check
|
|
108
|
+
// Check config.json exists
|
|
109
109
|
const stateDir = deps.resolveStateDir()
|
|
110
|
-
const routingJson = join(stateDir, '
|
|
110
|
+
const routingJson = join(stateDir, 'config.json')
|
|
111
111
|
if (!deps.existsSync(routingJson)) {
|
|
112
|
-
console.error(`missing prerequisite:
|
|
112
|
+
console.error(`missing prerequisite: config.json not found at ${routingJson}`)
|
|
113
113
|
deps.exit(1)
|
|
114
114
|
}
|
|
115
115
|
|