claude-slack-channel-bots 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,13 +36,25 @@ See the sections below for manual configuration details if you prefer not to use
36
36
 
37
37
  ## Prerequisites
38
38
 
39
- - [Bun](https://bun.sh) v1.0+
40
- - [tmux](https://github.com/tmux/tmux) (required for server-managed sessions)
39
+ - [Bun](https://bun.sh) `>= 1.0.21` (agent-director minimum)
41
40
  - [Claude Code](https://claude.ai/code) installed and authenticated
42
- - `ss` from [iproute2](https://github.com/iproute2/iproute2) on your `PATH` (required for session ID discovery; pre-installed on most Linux distributions)
43
- - `curl` and `jq` on your `PATH` (required for the permission relay hooks)
41
+ - [`agent-director`](https://github.com/gabemahoney/agent-director) **runtime dependency** — pulled in transitively when you install this package. Hard required at server boot: CSCB refuses to start if the library is missing, the host platform is unsupported, Bun is too old, or the installed version is below `MIN_AD_VERSION` (`^0.4.3`). agent-director itself requires [tmux](https://github.com/tmux/tmux) on the operator's PATH; CSCB no longer probes for it directly.
44
42
  - Slack workspace admin access (to create and configure the Slack app)
45
- - **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — enables session file cleaning before `--resume` for faster load times
43
+ - **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — used by JSONL path resolution helpers retained for downstream callers.
44
+
45
+ ### Supported platforms (inherited from agent-director)
46
+
47
+ | Platform | Status |
48
+ |---|---|
49
+ | `linux-x64` | Supported |
50
+ | `darwin-arm64` (Apple Silicon Mac) | Supported |
51
+ | `linux-arm64` | **Not supported** by agent-director |
52
+ | `darwin-x64` (Intel Mac) | **Not supported** by agent-director |
53
+ | Windows | **Not supported** by agent-director |
54
+
55
+ If the host is unsupported, the SR-5.1 startup gate exits non-zero at server boot with a typed error from agent-director (`ErrPlatformPackageMissing` or `ErrUnsupportedPlatform`) and writes the failure to `~/.claude/channels/slack/startup-errors.log` and stderr. See [Startup errors](#startup-errors) below.
56
+
57
+ > **Note on agent-director versions.** v0.4.1 is a zombie release (the published tarball is missing `dist/` and cannot be imported). v0.4.2 lacks the `MakeTemplateParams.overwrite` field CSCB needs for the boot-time template refresh. CSCB pins `^0.4.3` to get past both.
46
58
 
47
59
  ---
48
60
 
@@ -105,7 +117,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
105
117
 
106
118
  | Field | Type | Default | Description |
107
119
  |---|---|---|---|
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. |
120
+ | `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
121
  | `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
122
  | `default_dm_session` | string | — | CWD path of the session that handles direct messages. Must match an existing route `cwd`. |
111
123
  | `bind` | string | `"127.0.0.1"` | Interface the HTTP server binds to. Use `"0.0.0.0"` to expose on all interfaces. |
@@ -118,6 +130,31 @@ A skeleton file is created by postinstall. Populate it before running `start`.
118
130
  | `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
131
  | `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
132
  | `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
133
+ | `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. |
134
+ | `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. |
135
+ | `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). |
136
+ | `agent_director_poll_interval_ms` | number | `1000` | Poll interval (ms) for the agent-director permission relay tick. Must be a positive integer in `[200, 3_600_000]`. Replaces the pre-rename `claude_director_poll_interval_ms` — the old name is rejected at startup. Unknown top-level config fields are also rejected to surface stale configs after the rename. |
137
+
138
+ #### Per-route `claude_config_dir` override
139
+
140
+ 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.
141
+
142
+ ```json
143
+ {
144
+ "routes": {
145
+ "C_PERSONAL": {
146
+ "cwd": "~/projects/alpha",
147
+ "claude_config_dir": "~/.claude-maxauth"
148
+ },
149
+ "C_CORPORATE": {
150
+ "cwd": "~/projects/beta"
151
+ }
152
+ },
153
+ "claude_config_dir": "~/.claude-corp"
154
+ }
155
+ ```
156
+
157
+ `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
158
 
122
159
  ---
123
160
 
@@ -193,10 +230,11 @@ Checks prerequisites, then daemonizes the server.
193
230
 
194
231
  **Prerequisite checks (in order):**
195
232
 
196
- 1. `tmux` is on `PATH` — fails with `missing prerequisite: tmux` if not found.
197
- 2. `SLACK_BOT_TOKEN` is set — fails with `missing prerequisite: SLACK_BOT_TOKEN environment variable` if absent.
198
- 3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
199
- 4. `config.json` exists at `STATE_DIR/config.json` — fails with the full path if not found.
233
+ 1. `SLACK_BOT_TOKEN` is set — fails with `missing prerequisite: SLACK_BOT_TOKEN environment variable` if absent.
234
+ 2. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
235
+ 3. `config.json` exists at `STATE_DIR/config.json` — fails with the full path if not found.
236
+
237
+ Once the server daemonizes, the SR-5.1 startup gate runs inside the child process: it imports `agent-director`, constructs the singleton Client, runs `client.version()`, and verifies `~/.agent-director/state.db` is owned by the current user. Failures land in `startup-errors.log` (see [Startup errors](#startup-errors)). The previous `tmux -V` probe at the CLI level has been removed — agent-director enforces tmux availability at spawn time.
200
238
 
201
239
  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.
202
240
 
@@ -222,11 +260,11 @@ Gracefully exits all managed Claude Code sessions, then stops and starts the ser
222
260
  claude-slack-channel-bots clean_restart
223
261
  ```
224
262
 
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.
263
+ For each configured route, calls `client.pause({claude_instance_id})` via agent-director and polls `client.status(...)` until the spawn transitions to `ended` / `missing` (or `client.list(...)` returns no row). If the spawn does not exit within `exit_timeout` seconds (default 120s), the spawn is force-killed via `client.kill(...)`. All routes are processed in parallel. Individual session errors are logged and do not abort the restart. After the server restarts, the SR-1.4 collision-then-act dispatcher decides resume-vs-fresh per route agent-director owns Claude session-id state, not CSCB.
226
264
 
227
265
  Behavior by case:
228
266
 
229
- - **No sessions.json or no sessions:** skips the shutdown phase and proceeds directly to stop/start.
267
+ - **No configured routes:** skips the shutdown phase and proceeds directly to stop/start.
230
268
  - **Server already stopped:** `stop` reports `server is not running`; `start` then brings up a fresh server.
231
269
 
232
270
  ### PID file
@@ -271,69 +309,73 @@ Each MCP endpoint exposes the following tools to the connected Claude Code sessi
271
309
 
272
310
  ---
273
311
 
274
- ## Permission Relay
312
+ ## Interject
275
313
 
276
- 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.
314
+ 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.
277
315
 
278
- 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.
316
+ ### Request
279
317
 
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.
318
+ ```sh
319
+ curl -X POST http://localhost:<port>/interject \
320
+ -H "Content-Type: application/json" \
321
+ -d '{"channel": "C1234567890", "message": "Hello from a script", "sender": "my-cron-job"}'
322
+ ```
281
323
 
282
- Both hooks use a **two-phase long-poll protocol**:
324
+ | Field | Required | Description |
325
+ |---|---|---|
326
+ | `channel` | yes | Slack channel ID matching an entry in `config.json → routes`. |
327
+ | `message` | yes | Text to inject into the session. |
328
+ | `sender` | no | Label attached to the injected message. Defaults to `"interject"`. |
283
329
 
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`.
285
- 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.
330
+ ### Response
286
331
 
287
- ### Slack app prerequisites
332
+ On success, returns HTTP 200:
288
333
 
289
- The Slack app must have **interactivity enabled** with **Socket Mode** as the delivery method. Without this, button-click payloads are never delivered and the relay will not work.
334
+ ```json
335
+ { "ok": true, "channel": "C1234567890", "cwd": "/path/to/session" }
336
+ ```
290
337
 
291
- To enable it: open your Slack app config → **Interactivity & Shortcuts** → toggle **Interactivity** on. No Request URL is needed — Socket Mode delivers interaction payloads over the existing socket connection. This is included automatically if you created the app from `slack-app-manifest.yml`.
338
+ ### Error conditions
292
339
 
293
- ### Hook installation
340
+ | Status | Meaning |
341
+ |---|---|
342
+ | 400 | Invalid JSON or missing required field (`channel` or `message`). |
343
+ | 403 | Request did not originate from localhost. |
344
+ | 404 | Channel not found in `config.json → routes`. |
345
+ | 405 | Must use POST method. |
346
+ | 413 | Request body exceeds 32KB. |
347
+ | 503 | Channel is routed but no active session is connected. |
294
348
 
295
- 1. Copy the hook scripts from the repo to `~/.claude/hooks/`:
349
+ ### Example: crontab reminder
296
350
 
297
- ```sh
298
- cp hooks/permission-relay.sh hooks/ask-relay.sh ~/.claude/hooks/
299
- chmod +x ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
300
- ```
351
+ ```sh
352
+ # crontab -e
353
+ 0 9 * * 1 curl -s -X POST http://localhost:3100/interject \
354
+ -H "Content-Type: application/json" \
355
+ -d '{"channel": "C1234567890", "message": "Weekly reminder: update the changelog before standup.", "sender": "cron"}'
356
+ ```
301
357
 
302
- Alternatively, symlink them so updates to the repo are reflected automatically:
358
+ ---
303
359
 
304
- ```sh
305
- ln -sf /path/to/repo/hooks/permission-relay.sh ~/.claude/hooks/permission-relay.sh
306
- ln -sf /path/to/repo/hooks/ask-relay.sh ~/.claude/hooks/ask-relay.sh
307
- ```
360
+ ## Permission Relay
308
361
 
309
- 2. Ensure `curl` and `jq` are on your `PATH`.
310
-
311
- 3. Add the following to your Claude Code `settings.json`:
312
-
313
- ```jsonc
314
- "PermissionRequest": [
315
- {
316
- "matcher": ".*",
317
- "timeout": 2000000,
318
- "hooks": [{ "type": "command", "command": "~/.claude/hooks/permission-relay.sh" }]
319
- }
320
- ],
321
- "PreToolUse": [
322
- {
323
- "matcher": "AskUserQuestion",
324
- "timeout": 2000000,
325
- "hooks": [{ "type": "command", "command": "~/.claude/hooks/ask-relay.sh" }]
326
- }
327
- ]
328
- ```
362
+ When Claude Code requires tool approval, the permission relay surfaces an interactive Slack message with **Allow** and **Deny** buttons instead of blocking the TUI. Architecture is polling-based on the `agent-director` library — there are **no hook scripts to install** and no HTTP long-poll loops.
363
+
364
+ Flow:
329
365
 
330
- `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.
366
+ 1. agent-director moves the spawn into `check_permission` state when Claude requests a tool permission.
367
+ 2. CSCB's poller (`src/permission-poller.ts`) runs `client.list({ state: ['check_permission'], label: ['service=cscb'] })` at the `agent_director_poll_interval_ms` cadence (default 1000 ms).
368
+ 3. For each new spawn, `client.get(...)` returns the open `permission_request` (tool name + tool input + integer `request_id`). CSCB `chat.postMessage`s the Block Kit prompt to the spawn's `channel` label.
369
+ 4. The operator clicks Allow / Deny in Slack. CSCB's interactive handler calls `client.decide({ claude_instance_id, decision })` and `chat.update`s the message to "Allowed/Denied by <user>".
370
+ 5. If a tracked prompt drops out of `check_permission` for any reason other than a Slack click (timeout, external `decide`, crash), the next poller tick replaces the buttons with "expired".
331
371
 
332
- 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.
372
+ ### Slack app prerequisites
373
+
374
+ The Slack app must have **interactivity enabled** with **Socket Mode** as the delivery method. Open your Slack app config → **Interactivity & Shortcuts** → toggle **Interactivity** on. No Request URL is needed; Socket Mode delivers interaction payloads over the existing socket. This is included automatically if you created the app from `slack-app-manifest.yml`.
333
375
 
334
- ### Setup skill
376
+ ### AskUserQuestion
335
377
 
336
- The `update-config` skill can automate hook installation. It copies or symlinks the hooks and writes the `settings.json` entries in one step use it if you prefer not to configure hooks manually.
378
+ The `AskUserQuestion` tool is denied for every CSCB-spawned bot via the agent-director template (`deny: ['AskUserQuestion']`). Bots respond to operator questions via the Slack `reply` MCP tool instead. There is no `ask-relay.sh` hook and no `/ask` HTTP route.
337
379
 
338
380
  ---
339
381
 
@@ -355,10 +397,66 @@ After inviting the bot to a channel, Slack may not deliver messages until the bo
355
397
  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
398
 
357
399
  **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.
400
+ Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify the bot is in `check_permission` state via `agent-director list --state check_permission --label service=cscb` (operator CLI). Inspect `server.log` for `permission-poller:` lines skipped-tick WARNs at 5+ consecutive skips signal that the poll interval is too tight; increase `agent_director_poll_interval_ms` in `config.json`.
359
401
 
360
402
  **Session not restarting after crash**
361
403
  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
404
 
363
405
  **Session stuck during clean_restart**
364
- 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`.
406
+ If a session does not exit within `exit_timeout` seconds (default 120s), `clean_restart` force-kills the spawn via `agent-director kill` and proceeds. To manually recover, run `agent-director list --label service=cscb` to find lingering spawns and `agent-director kill <claude_instance_id>` to clear them, then `claude-slack-channel-bots stop && claude-slack-channel-bots start`.
407
+
408
+ **Session crashes on resume with "sandbox required but unavailable"**
409
+ 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:
410
+
411
+ ```json
412
+ {
413
+ "routes": { ... },
414
+ "resume_enabled": false
415
+ }
416
+ ```
417
+
418
+ ---
419
+
420
+ ## Startup errors
421
+
422
+ CSCB writes fatal startup errors to `~/.claude/channels/slack/startup-errors.log` (override the directory with `SLACK_STATE_DIR`) in addition to stderr. Each entry is a single timestamped line. The file is append-only and never rotated by CSCB — copy `docs/logrotate-startup-errors.conf` into `/etc/logrotate.d/` if you want host-level rotation.
423
+
424
+ Classes you may see:
425
+
426
+ - `ad-platform-package-missing` — agent-director's platform-native peer dependency is absent. The host is unsupported by agent-director.
427
+ - `ad-unsupported-platform` — agent-director's runtime check rejected the host's `process.platform`/`process.arch` tuple.
428
+ - `ad-bun-version-too-old` — agent-director needs Bun `>= 1.0.21`. Upgrade Bun.
429
+ - `ad-cli-not-executable` — the resolved agent-director CLI binary exists but is not executable. Run `chmod +x` on the binary referenced in the log line.
430
+ - `ad-version-probe` — `agent-director` was loaded but the `version()` probe failed (subprocess invocation, platform binary, etc.).
431
+ - `ad-version-stale` — installed `agent-director` is below the minimum version this CSCB requires. Run `bun add agent-director@^<minimum>`.
432
+ - `ad-call-timeout` — an agent-director verb call exceeded the configured `callTimeoutMs` (default 30 s). Investigate the subprocess or increase the timeout.
433
+ - `ad-same-user` — `~/.agent-director/state.db` is owned by a different UID than the CSCB process. Reinstall agent-director as the correct user or remove the mismatched file.
434
+ - `ad-same-user-stat` — Non-ENOENT stat error on the state DB (permissions, I/O). Investigate the file before re-launching.
435
+ - `ad-template-install` — `client.makeTemplate(...)` rejected the boot-time refresh of the `slack-channel-bot` template. The line includes the agent-director `errName`.
436
+
437
+ ---
438
+
439
+ ## Migration
440
+
441
+ For operators upgrading from a pre-`agent-director` install:
442
+
443
+ 1. **Install the new CSCB**: `bun remove claude-director` (if present) and `bun install -g claude-slack-channel-bots@^<new>`. The `agent-director` library is pulled in transitively — no separate install step.
444
+ 2. **Delete any old relay hooks** (CSCB no longer ships them — agent-director's relay machinery owns the tool-permission flow):
445
+ ```sh
446
+ rm -f ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
447
+ ```
448
+ Also remove their entries from `~/.claude/settings.json` if you wired them in by hand previously.
449
+ 3. **Configure agent-director's `find-missing` sweep**. CSCB does NOT call `client.findMissing(...)`; reconciling stuck rows is the operator's responsibility. Add a cron entry (or systemd timer) that runs `agent-director find-missing` on a cadence that matches your tolerance — e.g. every minute on a busy host:
450
+ ```cron
451
+ * * * * * /usr/local/bin/agent-director find-missing --timeout 30s
452
+ ```
453
+ 4. **Install the startup-errors logrotate snippet** so `~/.claude/channels/slack/startup-errors.log` doesn't grow unboundedly. Edit `USER` in the file to match the OS account running CSCB:
454
+ ```sh
455
+ sudo cp docs/logrotate-startup-errors.conf /etc/logrotate.d/claude-slack-channel-bots
456
+ ```
457
+ 5. **Rename your config field**. Pre-Epic-2 configs used `claude_director_poll_interval_ms`; CSCB now expects `agent_director_poll_interval_ms` and rejects the old name (no silent alias). Edit `~/.claude/channels/slack/config.json` accordingly. Unknown top-level fields are also rejected — clear any other deprecated keys.
458
+ 6. **Optional cleanup**: `~/.claude/channels/slack/sessions.json` and `sessions.json.last` are no longer read or written. CSCB ignores them; you can safely `rm` them after a successful boot.
459
+ 7. **`tmux` is no longer a CSCB-direct prereq** but is still required transitively via agent-director — keep it installed.
460
+
461
+ After step 1, every CSCB bot is spawned through `client.spawn(...)` with `relay_mode='on'`. The green/red Slack button UX is byte-identical to the pre-migration behavior; the action_id shape changes from `perm_(allow|deny)_<uuid>` to `perm_(allow|deny)_cscb_<channelId>_<request_id>` but this is invisible to end users.
462
+
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.6.0",
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"
@@ -10,6 +10,7 @@
10
10
  "src/*.ts",
11
11
  "!src/*.test.ts",
12
12
  "hooks/",
13
+ "scripts/fixup-bun-cache.ts",
13
14
  "slack-app-manifest.yml",
14
15
  "README.md",
15
16
  "skills/"
@@ -18,15 +19,18 @@
18
19
  "bun": ">=1.0.0"
19
20
  },
20
21
  "scripts": {
21
- "postinstall": "bun src/postinstall.ts",
22
+ "postinstall": "bun src/postinstall.ts && bun scripts/fixup-bun-cache.ts",
22
23
  "start": "bun install --no-summary && bun src/server.ts",
24
+ "pretest": "bun scripts/fixup-bun-cache.ts && bun scripts/check-no-toplevel-mock-module.ts",
25
+ "check:mocks": "bun scripts/check-no-toplevel-mock-module.ts",
23
26
  "test": "bun test",
24
27
  "typecheck": "tsc --noEmit"
25
28
  },
26
29
  "dependencies": {
27
30
  "@modelcontextprotocol/sdk": "^1.0.0",
28
31
  "@slack/socket-mode": "^2.0.0",
29
- "@slack/web-api": "^7.0.0"
32
+ "@slack/web-api": "^7.0.0",
33
+ "agent-director": "^0.5.1"
30
34
  },
31
35
  "devDependencies": {
32
36
  "@types/bun": "^1.0.0",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * fixup-bun-cache.ts — Workaround for bun's tarball-extraction bug.
4
+ *
5
+ * bun (observed in 1.3.13) sometimes drops files during tarball extraction when
6
+ * a directory and a file share a name prefix in the same parent — e.g.
7
+ * node_modules/ajv/dist/core.js (file, dropped)
8
+ * node_modules/ajv/dist/compile/ (sibling directory)
9
+ * or
10
+ * node_modules/zod/v4/core/core.js (file, dropped)
11
+ * node_modules/zod/v4/core/ (parent directory of itself)
12
+ *
13
+ * The cache entry under ~/.bun/install/cache/<name>@<version>@@@N contains the
14
+ * complete tarball contents. Once a broken extraction has been hardlinked into
15
+ * node_modules, subsequent `bun install` invocations reuse the broken state.
16
+ *
17
+ * This script walks every installed package, finds its cache directory, and
18
+ * hardlinks (or copies) any file present in the cache but missing in node_modules.
19
+ * Safe to re-run.
20
+ */
21
+
22
+ import {
23
+ readdirSync,
24
+ readFileSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ linkSync,
28
+ copyFileSync,
29
+ } from 'fs'
30
+ import { join, dirname } from 'path'
31
+ import { homedir } from 'os'
32
+
33
+ const ROOT = process.cwd()
34
+ const NM = join(ROOT, 'node_modules')
35
+ const CACHE = join(homedir(), '.bun', 'install', 'cache')
36
+
37
+ function* walkPackages(dir: string): Generator<string> {
38
+ if (!existsSync(dir)) return
39
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
40
+ if (!entry.isDirectory()) continue
41
+ if (entry.name === '.bin' || entry.name === '.cache') continue
42
+ const child = join(dir, entry.name)
43
+ if (entry.name.startsWith('@')) {
44
+ for (const inner of readdirSync(child, { withFileTypes: true })) {
45
+ if (inner.isDirectory()) {
46
+ const innerPath = join(child, inner.name)
47
+ yield innerPath
48
+ const nested = join(innerPath, 'node_modules')
49
+ if (existsSync(nested)) yield* walkPackages(nested)
50
+ }
51
+ }
52
+ } else {
53
+ yield child
54
+ const nested = join(child, 'node_modules')
55
+ if (existsSync(nested)) yield* walkPackages(nested)
56
+ }
57
+ }
58
+ }
59
+
60
+ function readVersion(pkgDir: string): string | null {
61
+ const pj = join(pkgDir, 'package.json')
62
+ if (!existsSync(pj)) return null
63
+ try {
64
+ return JSON.parse(readFileSync(pj, 'utf-8')).version ?? null
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ function* walkFiles(dir: string, base = dir): Generator<string> {
71
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
72
+ const child = join(dir, entry.name)
73
+ if (entry.isDirectory()) yield* walkFiles(child, base)
74
+ else yield child.slice(base.length + 1)
75
+ }
76
+ }
77
+
78
+ function findCacheDir(scope: string, name: string, version: string): string | null {
79
+ const base = scope ? join(CACHE, `@${scope}`) : CACHE
80
+ const prefix = `${name}@${version}@@@`
81
+ if (!existsSync(base)) return null
82
+ for (const entry of readdirSync(base)) {
83
+ if (entry.startsWith(prefix)) return join(base, entry)
84
+ }
85
+ return null
86
+ }
87
+
88
+ function pkgIdentity(pkgDir: string): { scope: string; name: string } {
89
+ const tail = pkgDir.split('/node_modules/').pop()!
90
+ const parts = tail.split('/')
91
+ if (parts[0].startsWith('@')) return { scope: parts[0].slice(1), name: parts[1] }
92
+ return { scope: '', name: parts[0] }
93
+ }
94
+
95
+ let scanned = 0
96
+ let restored = 0
97
+ const restoredPaths: string[] = []
98
+
99
+ for (const pkgDir of walkPackages(NM)) {
100
+ scanned++
101
+ const version = readVersion(pkgDir)
102
+ if (!version) continue
103
+ const { scope, name } = pkgIdentity(pkgDir)
104
+ const cacheDir = findCacheDir(scope, name, version)
105
+ if (!cacheDir) continue
106
+
107
+ for (const relFile of walkFiles(cacheDir)) {
108
+ const installedPath = join(pkgDir, relFile)
109
+ if (existsSync(installedPath)) continue
110
+ const cachePath = join(cacheDir, relFile)
111
+ mkdirSync(dirname(installedPath), { recursive: true })
112
+ try {
113
+ linkSync(cachePath, installedPath)
114
+ } catch {
115
+ copyFileSync(cachePath, installedPath)
116
+ }
117
+ restored++
118
+ restoredPaths.push(installedPath.slice(NM.length + 1))
119
+ }
120
+ }
121
+
122
+ if (restored > 0) {
123
+ for (const p of restoredPaths) console.log(`fixup-bun-cache: restored ${p}`)
124
+ }
125
+ console.log(`fixup-bun-cache: scanned ${scanned} packages, restored ${restored} file(s)`)
@@ -0,0 +1,133 @@
1
+ /**
2
+ * agent-director-client.ts — Module-level singleton wrapper for the
3
+ * `agent-director` library Client (SR-0.1).
4
+ *
5
+ * Public API:
6
+ * - getClient(): Client — lazy-construct + return the singleton
7
+ * - closeClient(): void — idempotent shutdown for SR-11 Event 11
8
+ * - MIN_AD_VERSION: string — semver string derived from package.json
9
+ * - DEFAULT_STORE_PATH / DEFAULT_TEMPLATE_NAME — paths CSCB pins
10
+ * - resetClientForTests(): void — test-only handle reset
11
+ *
12
+ * MIN_AD_VERSION is read once at module init from this package.json's
13
+ * `dependencies['agent-director']` range, stripping leading semver operators
14
+ * (`^`, `~`, `>=`, etc.) so a single source of truth covers both the install-
15
+ * time dep declaration (SR-5.3) and the runtime version gate (SR-5.1 step 3).
16
+ *
17
+ * Construction uses `{ storePath, createIfMissing: true, logger: console }`
18
+ * per SR-0.1; tilde expansion is the library's responsibility.
19
+ *
20
+ * Concurrency: AD's Client is internally safe for concurrent verb calls
21
+ * (the subprocess-CLI transport serializes its own dispatch) per SR-0.1,
22
+ * so CSCB does NOT add its own mutex.
23
+ *
24
+ * SPDX-License-Identifier: MIT
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs'
28
+ import { dirname, join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { Client } from 'agent-director'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Default AD store path; tilde-expanded library-side. */
37
+ export const DEFAULT_STORE_PATH = '~/.agent-director/state.db'
38
+
39
+ /** Name of the CSCB-shipped template (SR-3.1). */
40
+ export const DEFAULT_TEMPLATE_NAME = 'slack-channel-bot'
41
+
42
+ /**
43
+ * Resolve the package.json that ships with this module, then extract and
44
+ * normalize `dependencies['agent-director']` into a bare semver string.
45
+ *
46
+ * Throws at module init if the dep is missing or shaped wrong — that's a
47
+ * packaging bug worth failing loudly on, not a runtime condition.
48
+ */
49
+ function readMinAdVersion(): string {
50
+ const moduleDir = dirname(fileURLToPath(import.meta.url))
51
+ // src/agent-director-client.ts → package.json lives one level up
52
+ const pkgPath = join(moduleDir, '..', 'package.json')
53
+ const raw = readFileSync(pkgPath, 'utf-8')
54
+ const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> }
55
+ const range = pkg.dependencies?.['agent-director']
56
+ if (typeof range !== 'string' || range.length === 0) {
57
+ throw new Error(
58
+ `agent-director-client: package.json dependencies['agent-director'] is missing or empty — cannot derive MIN_AD_VERSION`,
59
+ )
60
+ }
61
+ // Strip leading semver operators: ^, ~, >=, >, =, v. Anything left should
62
+ // parse as semver; we don't validate further here — the SR-5.1 version gate
63
+ // does the actual comparison.
64
+ const stripped = range.replace(/^[\^~]|^>=|^>|^=/, '').replace(/^v/, '').trim()
65
+ if (stripped.length === 0) {
66
+ throw new Error(
67
+ `agent-director-client: dependencies['agent-director']=${range} reduced to empty string after stripping operators`,
68
+ )
69
+ }
70
+ return stripped
71
+ }
72
+
73
+ /** Minimum agent-director version CSCB requires at runtime (SR-5.3). */
74
+ export const MIN_AD_VERSION: string = readMinAdVersion()
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Singleton state
78
+ // ---------------------------------------------------------------------------
79
+
80
+ let singleton: Client | null = null
81
+
82
+ /**
83
+ * Return the singleton Client, lazy-constructing it on first call.
84
+ *
85
+ * Construction is synchronous: all platform / Bun / subprocess-resolution
86
+ * errors fire eagerly here per SR-0.1, not at first verb call. Typed `Err*`
87
+ * subclasses propagate; the SR-5.1 startup gate is the only intended
88
+ * caller-of-record that branches them.
89
+ */
90
+ export function getClient(): Client {
91
+ if (singleton === null) {
92
+ singleton = new Client({
93
+ storePath: DEFAULT_STORE_PATH,
94
+ createIfMissing: true,
95
+ logger: console,
96
+ })
97
+ }
98
+ return singleton
99
+ }
100
+
101
+ /**
102
+ * Release the Client handle if open. Idempotent: a second call is a no-op.
103
+ * `client.close()` itself never throws per the library contract.
104
+ *
105
+ * Called from SR-11 Event 11 (graceful shutdown) inside a try/finally.
106
+ */
107
+ export function closeClient(): void {
108
+ if (singleton !== null) {
109
+ singleton.close()
110
+ singleton = null
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Test-only helper: drop the cached singleton WITHOUT closing it. Use when
116
+ * tests construct fake Clients via the agent-director-stub and want a clean
117
+ * slate per test. Production code must use closeClient() instead.
118
+ *
119
+ * @internal
120
+ */
121
+ export function resetClientForTests(): void {
122
+ singleton = null
123
+ }
124
+
125
+ /**
126
+ * Test-only helper: install a pre-built Client (typically a stub) as the
127
+ * singleton. Skip the Client construction path entirely.
128
+ *
129
+ * @internal
130
+ */
131
+ export function setClientForTests(client: Client): void {
132
+ singleton = client
133
+ }