claude-slack-channel-bots 0.11.1 → 0.13.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
@@ -1,6 +1,6 @@
1
1
  # Claude Slack Channel Bots
2
2
 
3
- A single HTTP MCP server that runs several independent Claude Code bots, called personas. Each persona has its own Slack app and identity (name and avatar), one Claude Code instance with its own working directory, and is reachable from the Slack channels it is configured into and, when its `dm.enabled` is `true`, by direct message. The server holds one Slack Socket Mode connection per persona and delivers each message to the persona whose app received it; each persona's tool calls may post only to the channels it is configured into and, when its `dm.enabled` is `true`, to its direct messages, as that persona.
3
+ A single HTTP MCP server that runs several independent Claude Code bots, called personas. Each persona has its own Slack app and identity (name and avatar) and one Claude Code instance with its own working directory. It is reachable by direct message when its `dm.enabled` is `true`, and in the Slack channels the server's channel mode gives it: in declarative mode, the default, the channels it is configured into; in fungible mode, every public or private channel its Slack app is invited to that is not externally shared (see [Channel modes](#channel-modes)). The server holds one Slack Socket Mode connection per persona and delivers each message to the persona whose app received it. Each persona's tool calls act as that persona: in declarative mode only in the channels it is configured into, in fungible mode in any channel ID it names, where the call goes to Slack, which decides; and in its direct messages when its `dm.enabled` is `true`.
4
4
 
5
5
  ---
6
6
 
@@ -166,7 +166,8 @@ Runtime options are read from environment variables. None of them is required. S
166
166
 
167
167
  | Variable | Description |
168
168
  |---|---|
169
- | `SLACK_STATE_DIR` | Override the directory where `config.json` and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
169
+ | `SLACK_STATE_DIR` | Override the directory where `config.json` and runtime state are stored. Defaults to `~/.claude/channels/slack`. When it is set, the crontable's default moves there too (see `cron_table_path` under [Server-wide settings](#server-wide-settings)). |
170
+ | `XDG_CONFIG_HOME` | The standard XDG configuration directory. With `SLACK_STATE_DIR` unset and `XDG_CONFIG_HOME` an absolute path, the crontable defaults to `$XDG_CONFIG_HOME/cscb/crontab` instead of `~/.config/cscb/crontab`. An empty or relative value is ignored. |
170
171
  | `SLACK_DRY_RUN` | Set to `1` (or `true` / `yes`) to start the server without Slack. No credentials file is read and no Slack call is made. Each persona runs with a placeholder identity (`U000DRY_<key>`), and MCP tool calls (`reply`, `react`, etc.) and server notices are logged instead of sent. Useful for integration testing. |
171
172
  | `CSCB_LOG_MAX_BYTES` | Rotate `server.log` / `clean_restart.log` when the active file reaches this many bytes. Defaults to `10485760` (10 MiB). Values `<= 0` or non-numeric are ignored. |
172
173
  | `CSCB_LOG_KEEP` | Number of rotated generations to retain (`server.log.1` … `server.log.N`). Defaults to `5`. Set to `0` to keep none (the log is truncated instead of rolled). Values `< 0` or non-numeric are ignored. |
@@ -188,9 +189,9 @@ export SLACK_DRY_RUN=1
188
189
 
189
190
  `config.json` is read from `~/.claude/channels/slack/config.json` by default. Override the directory with `SLACK_STATE_DIR`. The server needs only this file and the credentials files it names.
190
191
 
191
- Each bot is a **persona**: one Slack app, one Claude instance with its own working directory, and the channels it is configured into. Create one Slack app per persona (see `slack-app-manifest.yml`); that app gives the persona its own name and avatar in Slack.
192
+ Each bot is a **persona**: one Slack app, one Claude instance with its own working directory, and the channels it serves. In declarative mode, the default, those are the channels it is configured into, and its tools may target only them; in fungible mode, they are the public and private channels its app is invited to that are not externally shared, and its tools may target any channel ID: the call goes to Slack, which decides (see [Tools](#tools) and [Channel modes](#channel-modes)). Create one Slack app per persona (see `slack-app-manifest.yml`); that app gives the persona its own name and avatar in Slack.
192
193
 
193
- The top level holds a required `personas` array and the [server-wide settings](#server-wide-settings). Unknown keys are rejected at every level: the top level, each persona, its `dm` object and each channel entry. Only this persona format is accepted; a configuration written for an earlier major version must be rewritten by hand (see [Upgrading to personas](#upgrading-to-personas)).
194
+ The top level holds a required `personas` array and the [server-wide settings](#server-wide-settings). Unknown keys are rejected at every level: the top level, each persona, its `dm` object, its `invited` object in fungible mode, and each channel entry in declarative mode (see [Load-time rules](#load-time-rules)). Only this persona format is accepted; a configuration written for an earlier major version must be rewritten by hand (see [Upgrading to personas](#upgrading-to-personas)).
194
195
 
195
196
  The first start of an install applies the file as it stands. After that, the server runs a recorded copy, and an edit changes nothing until you confirm it; a server restart alone doesn't apply it. See [Reload](#reload), and [What a confirmation applies](#what-a-confirmation-applies) for how each kind of change is applied.
196
197
 
@@ -204,7 +205,7 @@ Postinstall creates a skeleton with an empty persona list. An empty list is vali
204
205
 
205
206
  #### Example
206
207
 
207
- A complete `config.json` with three personas:
208
+ A complete `config.json` in declarative mode (`allow_invited_channels` absent), with three personas:
208
209
 
209
210
  - `planner` receives every message in its home channel `C0123456789` and in the shared channel `C0555555555`. Its permission prompts and notices go to its home channel. It takes no DMs.
210
211
  - `reviewer` receives only its @mentions and `@here` / `@channel` broadcasts in the shared channel. It takes DMs, and its prompts and notices go by DM to its contact `U0123456789`. It has its own Claude config directory and turns the [Slack Reply Guard](#slack-reply-guard-stop-hook) off for itself.
@@ -256,6 +257,35 @@ A complete `config.json` with three personas:
256
257
 
257
258
  Replace the channel IDs, user IDs and paths with your own. Each persona's credentials file must exist with its tokens before that persona can come up (see [Credentials files](#credentials-files)).
258
259
 
260
+ #### Example in fungible mode
261
+
262
+ A complete `config.json` in fungible mode (`allow_invited_channels: true`), with two personas. Neither lists `channels` or a top-level `permission_prompts`: in fungible mode they are not read.
263
+
264
+ - `triage` serves every public or private channel its Slack app is in that is not externally shared. Its permission prompts and notices go to the channel `C0123456789`, set by `invited.permission_prompts`; the channel needs no listing. It takes no DMs.
265
+ - `concierge` serves every public or private channel its Slack app is in that is not externally shared, and takes DMs. It sets no `invited.permission_prompts`, so its destination is `"dm"` by default: its prompts and notices go by DM to its contact `U0123456789`.
266
+
267
+ Invite each persona's Slack app to the channels it should serve, and `triage`'s app to its destination channel. Replace the channel ID, user ID and paths with your own.
268
+
269
+ ```json
270
+ {
271
+ "personas": [
272
+ {
273
+ "name": "triage",
274
+ "credentials_file": "~/.config/cscb/triage-credentials.json",
275
+ "working_directory": "~/projects/triage",
276
+ "invited": { "permission_prompts": "C0123456789" }
277
+ },
278
+ {
279
+ "name": "concierge",
280
+ "credentials_file": "~/.config/cscb/concierge-credentials.json",
281
+ "working_directory": "~/projects/concierge",
282
+ "dm": { "enabled": true, "contact": "U0123456789" }
283
+ }
284
+ ],
285
+ "allow_invited_channels": true
286
+ }
287
+ ```
288
+
259
289
  #### Persona fields
260
290
 
261
291
  | Field | Type | Required | Default | Description |
@@ -263,18 +293,19 @@ Replace the channel IDs, user IDs and paths with your own. Each persona's creden
263
293
  | `name` | string | yes | — | Any non-empty string; no format rule. See [Persona name and key](#persona-name-and-key). |
264
294
  | `credentials_file` | path | yes | — | Path to the persona's [credentials file](#credentials-files). |
265
295
  | `working_directory` | path | yes | — | Working directory of the persona's Claude instance. It must exist before the persona can come up. |
266
- | `channels` | array | yes, unless `dm.enabled` is `true` | none | The channels the persona is in: a list of [channel entries](#channel-entries). See [Channel delivery](#channel-delivery). |
296
+ | `channels` | array | in declarative mode, yes unless `dm.enabled` is `true` | none | Declarative mode only: the channels the persona is in, a list of [channel entries](#channel-entries). See [Channel delivery](#channel-delivery). Not read in fungible mode. |
267
297
  | `dm.enabled` | boolean | no | `false` | The persona's DMs switch. See [Direct messages](#direct-messages-dmenabled). |
268
- | `dm.contact` | string | when `permission_prompts` is `"dm"` | — | A Slack user ID such as `U0123456789`: the person a `"dm"` destination addresses. In Slack, open the person's profile, then **⋮** → **Copy member ID**. |
269
- | `permission_prompts` | string | yes | — | The persona's **destination**: where its permission prompts and server notices are posted. One of the persona's own channel IDs, or `"dm"`. See [Permission prompts](#permission-prompts). |
298
+ | `dm.contact` | string | when the persona's destination is `"dm"`, in either mode, including fungible mode's default `"dm"` | — | A Slack user ID such as `U0123456789`: the person a `"dm"` destination addresses. In Slack, open the person's profile, then **⋮** → **Copy member ID**. |
299
+ | `permission_prompts` | string | in declarative mode, yes | — | Declarative mode only: the persona's **destination**, where its permission prompts and server notices are posted. One of the persona's own channel IDs, or `"dm"`. See [Permission prompts](#permission-prompts). Not read in fungible mode. |
300
+ | `invited.permission_prompts` | string | no | `"dm"` | Fungible mode only: the persona's **destination**, where its permission prompts and server notices are posted. `"dm"` or a Slack channel ID such as `C0123456789`; the channel needs no listing. Not read in declarative mode. See [Permission prompts in fungible mode](#permission-prompts-in-fungible-mode). |
270
301
  | `claude_config_dir` | path | no | top-level `claude_config_dir` | Claude config directory for this persona. See [Next-launch settings](#next-launch-settings). |
271
302
  | `stop_hook_bootstrap` | boolean | no | top-level `stop_hook_bootstrap` | Slack Reply Guard switch for this persona. See [Next-launch settings](#next-launch-settings). |
272
303
 
273
- `dm` is an object holding `enabled` and `contact`: `"dm": { "enabled": true, "contact": "U0123456789" }`. A path is absolute, `~` or starts with `~/`. Paths are never redacted: a log line, preview or error that names a path shows it in full, with `~` expanded.
304
+ `dm` is an object holding `enabled` and `contact`: `"dm": { "enabled": true, "contact": "U0123456789" }`. It applies in both modes. `invited` is an object holding `permission_prompts`: `"invited": { "permission_prompts": "C0123456789" }`. A path is absolute, `~` or starts with `~/`. Paths are never redacted: a log line, preview or error that names a path shows it in full, with `~` expanded.
274
305
 
275
306
  #### Channel entries
276
307
 
277
- Each entry of `channels` is an object with two required fields:
308
+ Channel entries apply in declarative mode; in fungible mode a persona serves the channels its app is invited to (see [What each mode serves](#what-each-mode-serves)). Each entry of `channels` is an object with two required fields:
278
309
 
279
310
  | Field | Description |
280
311
  |---|---|
@@ -340,9 +371,11 @@ To rotate a persona's tokens, run the credentials command for it again (see the
340
371
 
341
372
  Never put a token in `config.json`, a ticket or a chat.
342
373
 
374
+ The shipped `slack-app-manifest.yml` also grants the `lists:read` and `lists:write` bot scopes, so a bot can read and write, with its own bot token, a Slack List it has been given access to, such as one list shared by all bots. CSCB itself makes no Lists call. An app created from an earlier manifest must have both scopes added and be re-installed; until then, every Lists call fails with `missing_scope`.
375
+
343
376
  #### Channel delivery
344
377
 
345
- Each channel entry's `delivery` sets which messages in that channel reach the persona:
378
+ This section applies in declarative mode; for fungible mode, see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode). Each channel entry's `delivery` sets which messages in that channel reach the persona:
346
379
 
347
380
  - **`all`**: every message in the channel.
348
381
  - **`mentions`**: only messages that @mention the persona directly, and `@here` / `@channel` broadcasts.
@@ -357,17 +390,17 @@ Any number of personas may list the same channel, each with its own `delivery`.
357
390
  - **Off:** DMs to the persona's app are not delivered. The server logs one `persona-dm-dropped` line naming the persona and `dm.enabled`. The persona never opens, reads or posts in a DM.
358
391
  - **Group DMs** (DMs with more than one person) are never delivered to any persona, whatever `dm.enabled` says.
359
392
 
360
- A persona with no `channels` must have `dm.enabled` set to `true`.
393
+ In declarative mode, a persona with no `channels` must have `dm.enabled` set to `true`.
361
394
 
362
- `dm.contact` is the person who receives the persona's prompts and notices when `permission_prompts` is `"dm"`. It gates nothing else: it doesn't limit who can DM the persona.
395
+ `dm.contact` is the person who receives the persona's prompts and notices when its destination is `"dm"`: in declarative mode when `permission_prompts` is `"dm"`, and in fungible mode when `invited.permission_prompts` is `"dm"` or unset. In either mode a `"dm"` destination needs `dm.enabled: true` and a `dm.contact`. `dm.contact` gates nothing else: it doesn't limit who can DM the persona.
363
396
 
364
- Starting a DM with a user (a `reply` to a user ID, or a persona whose `permission_prompts` is `"dm"`) needs the `im:write` bot scope, which the shipped `slack-app-manifest.yml` grants. A `"dm"` destination always opens its DM through Slack before it first posts there, even when the DM already exists. For an app created from an earlier manifest, a `"dm"` persona's prompts and notices are not posted, and a `reply` to a user ID fails with `missing_scope`, until the scope is added and the app is re-installed.
397
+ Starting a DM with a user (a `reply` to a user ID, or a persona whose destination is `"dm"`) needs the `im:write` bot scope, which the shipped `slack-app-manifest.yml` grants. A `"dm"` destination always opens its DM through Slack before it first posts there, even when the DM already exists. For an app created from an earlier manifest, a `"dm"` persona's prompts and notices are not posted, and a `reply` to a user ID fails with `missing_scope`, until the scope is added and the app is re-installed.
365
398
 
366
399
  Receiving DMs and replying in an existing DM (a `D…` ID) work without the scope. The `debug-slack-channel-bots` skill (see [Troubleshooting](#troubleshooting)) has the steps under "A persona can't open a DM".
367
400
 
368
401
  #### Permission prompts
369
402
 
370
- `permission_prompts` is required. It is the persona's destination: its permission prompts and its server notices, such as lost-message notices and restart-limit warnings, are posted there as the persona.
403
+ This section applies in declarative mode, where `permission_prompts` is required; for fungible mode, see [Permission prompts in fungible mode](#permission-prompts-in-fungible-mode). It is the persona's destination: its permission prompts and its server notices, such as lost-message notices and restart-limit warnings, are posted there as the persona.
371
404
 
372
405
  - **A channel ID:** one of the persona's own `channels`. Everyone in that channel sees the prompts and notices.
373
406
  - **`"dm"`:** a DM from the persona's app to its `dm.contact`. It needs `dm.enabled: true` and a `dm.contact`.
@@ -397,13 +430,13 @@ Use `--console` instead of `--claudeai` for a Console account. When neither the
397
430
 
398
431
  #### Server-wide settings
399
432
 
400
- These top-level fields apply to the whole server. A confirmed change to one takes effect at the next server start, with three exceptions. A change to `claude_config_dir` or `stop_hook_bootstrap` takes effect at each inheriting persona's next launch. `stop_timeout` and `exit_timeout` are used only by the CLI, which takes them from the record at once. `agent_director_call_timeout_ms` is used by both: the CLI takes it from the record at once, and the running server uses it from its next start. See [What a confirmation applies](#what-a-confirmation-applies).
433
+ These top-level fields apply to the whole server. A confirmed change to one takes effect at the next server start, with four exceptions. `allow_invited_channels` is the one setting a confirmation applies in place, at once. A change to `claude_config_dir` or `stop_hook_bootstrap` takes effect at each inheriting persona's next launch. `stop_timeout` and `exit_timeout` are used only by the CLI, which takes them from the record at once. `agent_director_call_timeout_ms` is used by both: the CLI takes it from the record at once, and the running server uses it from its next start. See [What a confirmation applies](#what-a-confirmation-applies).
401
434
 
402
435
  | Field | Type | Default | Description |
403
436
  |---|---|---|---|
404
437
  | `bind` | string | `"127.0.0.1"` | Interface the HTTP server binds to. Use `"0.0.0.0"` to expose on all interfaces. The in-process cron scheduler delivers via `127.0.0.1`, and [`clear-latch`](#claude-slack-channel-bots-clear-latch) reaches the server there, so `bind` must include loopback (the default, or `0.0.0.0`) for scheduled fires and `clear-latch` to work. |
405
438
  | `port` | number | `3100` | Port the HTTP server listens on. |
406
- | `session_restart_delay` | number | `60` | Seconds to wait before auto-restarting a dead session. Set to `0` to disable auto-restart and the health check's reconnects (not the retries of a persona agent-director refused; see [Troubleshooting](#troubleshooting)); a persona whose session runs but can't receive messages then gets a *Not connected* or *Not receiving messages* notice at its destination (see [Troubleshooting](#troubleshooting)). At any value, a persona reported as `working` that the server can't prove idle for 10 minutes gets a *Not connected* notice. Must be non-negative. |
439
+ | `session_restart_delay` | number | `60` | Seconds to wait before auto-restarting a session that crashed or disappeared. A session that ended cleanly (an `agent-director pause`, a `/exit`) is relaunched about 3 s after it disconnects instead, unless the persona's last launch failed or it had such a fast relaunch within the last `session_restart_delay` seconds (see "Session not restarting after crash" in [Troubleshooting](#troubleshooting)). Set to `0` to disable auto-restart, clean exits included, and the health check's reconnects (not the retries of a persona agent-director refused; see [Troubleshooting](#troubleshooting)); a persona whose session runs but can't receive messages then gets a *Not connected* or *Not receiving messages* notice at its destination (see [Troubleshooting](#troubleshooting)). At any value, a persona reported as `working` that the server can't prove idle for 10 minutes gets a *Not connected* notice. Must be non-negative. |
407
440
  | `health_check_interval` | number | `120` | Seconds between periodic liveness polls. Set to `0` to disable; the retries of a persona agent-director refused still run. Must be non-negative. |
408
441
  | `exit_timeout` | number | `120` | Seconds to wait for a managed Claude Code session to exit gracefully during `clean_restart` or `stop --stop-bots` before force-killing it through agent-director. |
409
442
  | `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
@@ -411,36 +444,55 @@ These top-level fields apply to the whole server. A confirmed change to one take
411
444
  | `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. |
412
445
  | `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. |
413
446
  | `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
414
- | `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. |
447
+ | `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. Each channel's name is looked up through the receiving persona's Slack app, which needs the `channels:read` and `groups:read` bot scopes that the shipped `slack-app-manifest.yml` grants. An app created from an earlier manifest stores the channel ID instead, until both scopes are added and the app is re-installed; the `debug-slack-channel-bots` skill has the steps under "The message archive shows channel IDs". A DM's rows keep the DM's `D…` ID whatever the scopes, as a DM has no name; a group DM's rows keep its ID too. Feature is disabled when absent. |
415
448
  | `claude_config_dir` | string | — | Default Claude on-disk config directory for every persona. When a persona has one (its own or this default), its session launches with `CLAUDE_CONFIG_DIR='<resolved-path>'` so the bot authenticates against a specific account. `~` is expanded and the path is resolved to absolute. A persona's own `claude_config_dir` overrides this value. When neither is set, Claude's own default applies. Must be non-empty when set. |
416
- | `resume_enabled` | boolean | `true` | When `true` (default), a bot whose session died — including after a host reboot or pod resume — comes back with its prior conversation history intact instead of starting fresh. When `false`, a bot is never resumed: on startup and on runtime auto-restart it starts a new conversation on the same instance, even when a stored session exists, and its old conversation is kept as an earlier life of that instance. Set `false` as a workaround if your Claude Code version crashes with "sandbox required but unavailable" on resume (a known regression in v2.1.120). |
449
+ | `resume_enabled` | boolean | `true` | When `true` (default), a bot whose session died — including after a host reboot or pod resume — comes back with its prior conversation history intact instead of starting fresh; after 2 resumes in a row that end before the session starts (a transcript Claude Code can't load), it starts fresh instead (see "Session not restarting after crash" under [Troubleshooting](#troubleshooting)). When `false`, a bot is never resumed: on startup and on runtime auto-restart it starts a new conversation on the same instance, even when a stored session exists, and its old conversation is kept as an earlier life of that instance. Set `false` as a workaround if your Claude Code version crashes with "sandbox required but unavailable" on resume (a known regression in v2.1.120). |
417
450
  | `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. |
418
451
  | `agent_director_call_timeout_ms` | number | `60000` | How long (ms) CSCB waits on each agent-director call it makes for its personas, in the server and in `stop --stop-bots` and `clean_restart`. Must be an integer in `[1000, 3_600_000]`; out-of-range values are rejected at load. It should be greater than the need that agent-director's timing settings give; see [Sizing the agent-director call timeout](#sizing-the-agent-director-call-timeout). |
419
452
  | `stop_hook_bootstrap` | boolean | `true` | Default for every persona: whether the persona gets the Slack Reply Guard reminder (see [Slack Reply Guard (Stop hook)](#slack-reply-guard-stop-hook)). The value applies per persona, from the persona's first launch after the change is applied (see [Reload](#reload)). A persona's own `stop_hook_bootstrap` overrides this value. Non-boolean values are rejected at startup. |
420
- | `cron_table_path` | string | `<config dir>/crontab` | Path to the crontable for the built-in cron scheduler (`cscb_cron`). Defaults to `crontab` in the directory of the loaded `config.json`. `~` is expanded like other path keys. The resolved path is exported into every managed session as `CSCB_CRONTABLE_PATH` so bots can find the crontable and self-schedule (see [Scheduled Prompts](#scheduled-prompts-cscb_cron)). Must be a non-empty string when set. |
421
- | `cron_log_path` | string | `<config dir>/cron.log` | Path to the `cscb_cron` log file. Defaults to `cron.log` in the directory of the loaded `config.json`. `~` is expanded like other path keys. Must be a non-empty string when set. |
422
- | `cron_log_max_bytes` | number | — | Size cap in bytes for the cron log. Must be a positive integer when set. Cron-log pruning is disabled when absent. |
453
+ | `cron_table_path` | string | `~/.config/cscb/crontab` | Path to the crontable for the built-in cron scheduler (`cscb_cron`). When absent, it is `~/.config/cscb/crontab` (`$XDG_CONFIG_HOME/cscb/crontab` when `XDG_CONFIG_HOME` is an absolute path), outside `~/.claude`, so a bot's edit is not a write under `~/.claude`, which Claude Code asks the user to approve. With `SLACK_STATE_DIR` set, the default is `crontab` in the directory of the loaded `config.json` instead. A path you set is used as written, with `~` expanded like other path keys, even when it names `~/.config/cscb/crontab`: the server never creates its directory or moves a crontable to it. The resolved path is exported into every managed session as `CSCB_CRONTABLE_PATH` so bots can find the crontable and self-schedule (see [Scheduled Prompts](#scheduled-prompts-cscb_cron)). Must be a non-empty string when set. |
454
+ | `cron_log_path` | string | `<config dir>/cron.log` | Path to the `cscb_cron` log file. Defaults to `cron.log` in the directory of the loaded `config.json`, wherever the crontable is. `~` is expanded like other path keys. Must be a non-empty string when set. |
455
+ | `cron_log_max_bytes` | number | — | Size cap in bytes for the cron log. Once a line takes the log past the cap, the server drops its oldest whole lines until it fits. The newest line is always kept, even when it alone is larger than the cap. Nothing is rotated or archived, so dropped lines are gone. Absent means no cap: the log only grows. Must be a positive integer when set. |
423
456
  | `ack_reaction` | string | — | Emoji name, without colons (for example `"eyes"`), of the acknowledgement reaction. When set, each persona a message is dispatched to adds the reaction under its own Slack identity once the message has reached its instance, not on receipt, so a message that reaches several personas carries one reaction per persona. A message that isn't dispatched gets no reaction: one lost because the persona's instance is down, or one the persona doesn't receive. Messages sent through `/interject` or cron aren't Slack messages and get none either. A persona's first `reply` in that conversation carrying the message's `message_id` removes that persona's reaction only; other personas' reactions stay until they reply. Absent means no acknowledgement. Must be non-empty when set. |
424
457
  | `reply_chunk_limit` | number | `4000` | Maximum characters per posted message: the `reply` tool splits longer text into several messages. Must be a positive integer. |
425
458
  | `reply_chunk_mode` | `"length"` \| `"newline"` | `"newline"` | How the `reply` tool splits text longer than `reply_chunk_limit`. `length`: hard split at the limit. `newline`: split at newline boundaries within the limit; a single line longer than the limit is posted whole. |
459
+ | `allow_invited_channels` | boolean | `false` | Picks the channel mode for every persona. Absent or `false` is **declarative mode**: each persona's `channels` and top-level `permission_prompts` are in force, and its `invited` section is not read. `true` is **fungible mode**: each persona's `channels` and top-level `permission_prompts` are not read, and its `invited` section is in force. A confirmed change applies in place at once and keeps every session. Non-boolean values are rejected at load. See [Channel modes](#channel-modes), and read [Who can reach a persona in fungible mode](#who-can-reach-a-persona-in-fungible-mode) before turning it on. |
426
460
 
427
461
  #### Load-time rules
428
462
 
429
- Before it applies `config.json`, the server checks the whole file and reports the first rule it breaks:
463
+ Before it applies `config.json`, the server checks the whole file and reports the first rule it breaks. It checks the server-wide settings first, `allow_invited_channels` among them, before any persona, in either mode: a non-boolean `allow_invited_channels` is rejected naming the setting. The mode that switch picks then sets which rules each persona entry is checked by.
464
+
465
+ In both modes:
430
466
 
431
467
  - A persona's name or key equals another persona's name or key.
432
468
  - A persona's key starts with another persona's key, such as `dev` and `dev_2` (see [Persona name and key](#persona-name-and-key)).
433
469
  - Two personas share a `working_directory` or a `credentials_file`, compared by real path.
470
+ - A `dm.contact` is malformed. User IDs start with `U` or `W`, followed by capital letters and digits only; underscores are not allowed.
471
+ - A path isn't absolute, `~` or `~/…`.
472
+ - A required field is missing, or a key of the top level, a persona entry or `dm` is unknown.
473
+ - A value has the wrong type, isn't an allowed value, or is out of range.
474
+
475
+ In declarative mode (`allow_invited_channels` absent or `false`):
476
+
434
477
  - `permission_prompts` names a channel that isn't in the persona's `channels`.
435
478
  - `permission_prompts` is `"dm"` without `dm.enabled: true` or without a `dm.contact`.
436
479
  - A persona has no channels and `dm.enabled` isn't `true`.
437
480
  - A persona lists the same channel twice.
438
- - A channel ID or `dm.contact` is malformed. Channel IDs start with `C` or `G` and user IDs with `U` or `W`, followed by capital letters and digits only; underscores are not allowed.
439
- - A path isn't absolute, `~` or `~/…`.
440
- - A required field is missing, or a key is unknown.
441
- - A value has the wrong type, isn't an allowed value, or is out of range.
481
+ - A channel ID is malformed, or a channel entry has an unknown key. Channel IDs start with `C` or `G`, followed by capital letters and digits only; underscores are not allowed.
442
482
 
443
- For a persona error, the error names the persona (`personas[<i>]`) and the field. Credentials content and whether directories exist are not checked here; they are checked when each persona comes up. At a start with no last-applied record, an error stops the start. On a running server, an error shows as an `INVALID` pending change (see [Reload](#reload)). The `debug-slack-channel-bots` skill (`skills/debug-slack-channel-bots/SKILL.md`) lists every rejection with its cause and fix under "Configuration rejections".
483
+ `invited` is neither checked nor read in declarative mode, so a malformed `invited` loads.
484
+
485
+ In fungible mode (`allow_invited_channels` is `true`):
486
+
487
+ - `invited` isn't an object, or holds a key other than `permission_prompts`.
488
+ - `invited.permission_prompts` is neither `"dm"` nor a well-formed channel ID.
489
+ - The persona's destination is `"dm"`, written or by default, without `dm.enabled: true` or without a `dm.contact`. The error names `invited.permission_prompts`, says whether `"dm"` is its default, and names each missing setting.
490
+
491
+ `channels` and the top-level `permission_prompts` are neither checked nor read in fungible mode, whatever they hold, and `permission_prompts` isn't required. A channel destination needs no listing and no DMs, and a persona with DMs off and a channel destination is valid.
492
+
493
+ A pending change is judged by the rules of the mode its own switch picks, not by the mode in effect. A change that turns fungible mode on is `INVALID` when a persona's `invited` section breaks the fungible-mode rules; a change that turns it off is `INVALID` when a persona's `channels` or `permission_prompts` breaks the declarative-mode rules.
494
+
495
+ For a persona error, the error names the persona (`personas[<i>]`) and the field. A value that a format, type, range or allowed-value rule rejects is never shown; a type error names only the JSON type it got. Credentials content and whether directories exist are not checked here; they are checked when each persona comes up. At a start with no last-applied record, an error stops the start. On a running server, an error shows as an `INVALID` pending change (see [Reload](#reload)). The `debug-slack-channel-bots` skill (`skills/debug-slack-channel-bots/SKILL.md`) lists every rejection with its cause and fix under "Configuration rejections".
444
496
 
445
497
  ---
446
498
 
@@ -542,6 +594,67 @@ For the second line, give `[pause] timeout_seconds` a positive whole number, or
542
594
 
543
595
  ---
544
596
 
597
+ ## Channel modes
598
+
599
+ The top-level setting `allow_invited_channels` picks the channel mode for every persona at once. The mode decides which channels a persona serves, how each channel's delivery is set, and where the persona's permission prompts and notices go.
600
+
601
+ ### What each mode serves
602
+
603
+ | | Declarative mode | Fungible mode |
604
+ |---|---|---|
605
+ | The switch | `allow_invited_channels` absent or `false`, the default | `allow_invited_channels: true` |
606
+ | Channels a persona serves | The channels listed in its `channels` | Every public or private channel its Slack app is a member of that Slack marks as not externally shared |
607
+ | Who sets a channel's delivery | The operator, with each channel entry's `delivery` | The persona's agent, with `set_channel_delivery`; `mentions` otherwise (see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode)) |
608
+ | Destination of prompts and notices | `permission_prompts` | `invited.permission_prompts`, `"dm"` when it is absent (see [Permission prompts in fungible mode](#permission-prompts-in-fungible-mode)) |
609
+ | Persona settings read | `channels` and the top-level `permission_prompts` | `invited` |
610
+
611
+ - **Each mode reads only its own section.** Neither reads nor overrides the other: in declarative mode `invited` is not read, and in fungible mode `channels` and the top-level `permission_prompts` are not read, whatever they hold. A persona may carry both sections, so switching back and forth needs no rewrite.
612
+ - **Direct messages follow `dm.enabled` in both modes.** Group DMs are never served.
613
+ - **Externally shared (Slack Connect) channels** are served only in declarative mode, with the channel listed.
614
+ - **Upgrades stay declarative.** A configuration without `allow_invited_channels` runs in declarative mode. Upgrading needs no migration, and nothing rewrites `config.json`: fungible mode begins only when you set the switch to `true`.
615
+ - **No Slack change.** Neither mode needs a Slack scope, a manifest change or an app re-install beyond the shipped `slack-app-manifest.yml`.
616
+
617
+ ### Turning fungible mode on or off
618
+
619
+ Before you turn fungible mode on, read [Who can reach a persona in fungible mode](#who-can-reach-a-persona-in-fungible-mode): an invite then changes who can reach a persona's worker.
620
+
621
+ The switch changes through a confirmed edit, like every other setting:
622
+
623
+ 1. Edit `config.json`. To turn fungible mode on, add `"allow_invited_channels": true` and give each persona the `invited` section it needs (see [Load-time rules](#load-time-rules)). To turn it off, remove the setting or set it to `false`.
624
+ 2. Read the pending preview. The switch has its own line, naming the mode it turns on and every persona it affects (see [Reading the preview](#reading-the-preview)).
625
+ 3. Confirm the change (see [Confirming a change](#confirming-a-change)). It applies in place, at once, from the next event, tool call, prompt and notice, with no restart, and every session is kept (see [What a confirmation applies](#what-a-confirmation-applies)).
626
+
627
+ **Turning it on.** From each channel's next message, fungible mode serves every public or private channel each persona's app is already in that is not externally shared. Each channel is served at `mentions` unless a stored choice applies. Listed `delivery` values are not carried over: a channel listed with `delivery: all` is served at `mentions` unless the persona's agent stores `all` for it.
628
+
629
+ Before you turn it on, review each persona's app's channel memberships in Slack, private channels included: each one becomes a channel the persona serves. Past `unclaimed-channel` lines in `server.log` show channels whose messages reached a persona's app with no persona serving them:
630
+
631
+ ```sh
632
+ grep unclaimed-channel ~/.claude/channels/slack/server.log*
633
+ ```
634
+
635
+ **Turning it off.** The change is checked by the declarative rules, so each persona needs a valid declarative section: a persona whose `channels` or `permission_prompts` breaks a declarative-mode rule makes the change `INVALID` (see [Load-time rules](#load-time-rules)). From the next event, each persona serves its listed channels again, each at its `delivery`. Turning it off changes no stored choice on disk: the choices stop applying, and they apply again when fungible mode is turned back on.
636
+
637
+ ### Channel delivery in fungible mode
638
+
639
+ In fungible mode, a persona's channel delivery for a channel sets which messages there reach it, as `delivery` does in declarative mode: `mentions` (only messages that @mention the persona directly, and `@here` / `@channel` broadcasts) or `all` (every message).
640
+
641
+ - **`mentions` is the default.** A channel is at `all` only when the persona's agent stored `all` for it with `set_channel_delivery` (see [Tools](#tools)). The agent calls the tool when someone in that channel asks it to listen to everything there, or to answer only when mentioned. Whether any persona lists the channel in `channels` plays no part.
642
+ - **The loop guard.** A channel that is another persona's fungible destination (its `invited.permission_prompts` channel) is held at `mentions` for every other persona, whatever they stored, so no other persona receives every message where that persona's prompts and notices are posted. A persona's own destination channel is not held for it, and a `"dm"` destination holds nothing.
643
+ - **Stored choices persist.** Each choice is kept in `channel-delivery.json` beside `config.json` (see [Files beside the config file](#files-beside-the-config-file)), across server restarts and persona relaunches, and applies from the next message after the call. A persona that is removed, renamed, or whose `credentials_file` or `working_directory` changes loses its stored choices and comes up at `mentions` everywhere.
644
+ - **`receive_all_shared` is an approximation.** A persona at `all` in a channel receives a message there as `receive_all_shared` when at least one other persona's channel delivery for that channel, computed by the same rule, is `all`. A stored choice says nothing about whether the other persona's app remains in the channel, so a persona whose app has left the channel counts too.
645
+ - **Don't keep two personas at `all` in one channel.** Each receives every message there, the other's posts included, so each answer one persona posts reaches the other, which can answer it in turn: the two can keep answering each other with no human involved. If one should listen to everything, keep the other at `mentions`.
646
+
647
+ ### Permission prompts in fungible mode
648
+
649
+ In fungible mode, a persona's destination is its `invited.permission_prompts`, `"dm"` when it is absent. Its permission prompts and its server notices, such as lost-message notices and restart-limit warnings, are posted there as the persona. See the `invited.permission_prompts` row in [Persona fields](#persona-fields).
650
+
651
+ - **A channel ID:** any channel; it needs no listing and no DMs. Invite the persona's app to it, or Slack refuses the posts (see "A permission prompt or notice doesn't arrive" in [Troubleshooting](#troubleshooting)). Everyone in that channel sees the prompts and notices.
652
+ - **`"dm"`, written or by default:** a DM from the persona's app to its `dm.contact`. It needs `dm.enabled: true` and a `dm.contact`.
653
+
654
+ Because of the loop guard, no other persona receives every message in a fungible destination channel. After a confirmed switch change, the next prompt and the next notice, held notices included, go to the destination of the mode turned on; prompts already posted stay where they are and stay answerable.
655
+
656
+ ---
657
+
545
658
  ## Reload
546
659
 
547
660
  Saving `config.json` doesn't change what runs. The server runs the last configuration it applied, and it shows an edit as a pending change until you confirm it (see [Confirming a change](#confirming-a-change)).
@@ -556,6 +669,7 @@ These files sit in the same directory as `config.json` (`~/.claude/channels/slac
556
669
  | `config.json.pending` | A preview of what applying the edit would do, written by the server. It exists only while a change is pending. |
557
670
  | `config.json.apply` | Your confirmation: the pending file, renamed. The server deletes it at its next check (see [Confirming a change](#confirming-a-change)). |
558
671
  | `retired-keys.json` | The retired-key record: the persona keys the server holds as retired. A confirmed change records the key of each removed persona (a renamed persona's old key included) and of each persona whose `name`, `credentials_file` or `working_directory` changed, before `config.json.last-applied` is rewritten. A server start also records, before its clean-up of old instances ends any of them, the key of every agent-director instance labelled with a persona that is not in the applied configuration (for example, a persona that `config.json` no longer names, at a start after `config.json.last-applied` was deleted; see [Start rules](#start-rules)), so a persona added back later with that key starts fresh. If that write fails, the start logs it in `server.log`, holds those keys as retired until the server stops, and records them again at the next start. Only the server writes it, never the CLI. It is kept across restarts and is absent until a key is first recorded. A key's entry is removed on its own once the persona's new session is running. If it can't be read, the server doesn't start; moving it aside is the fix (see `retired-keys-unreadable` under [Startup errors](#startup-errors)). |
672
+ | `channel-delivery.json` | The stored-choice file: each persona's stored channel-delivery choices (see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode)), by persona key and channel, with each persona's `name`, `credentials_file` and `working_directory`. It holds no token and no message text. Only the server reads and writes it, never the CLI. It is absent until the first accepted `set_channel_delivery` call, so it is never created in declarative mode. Never edit it by hand. The server reads it once at each start. A persona's choices are dropped when a confirmed change retires its key (the persona is removed, or its `name`, `credentials_file` or `working_directory` changes), and at a start when its key is not an applied persona, its `name`, `credentials_file` or `working_directory` differs from the one stored, or `retired-keys.json` holds its key as retired and the persona's new session has not yet begun; each drop logs a `[slack] channel-delivery:` line. If the file can't be read, parsed or validated at a start, the server leaves it in place, logs one [`channel-delivery-unreadable`](skills/debug-slack-channel-bots/SKILL.md#channel-delivery-unreadable) line, and starts and runs every persona, but for that run no stored choice applies: in fungible mode every channel is at `mentions`, `set_channel_delivery` is refused, and nothing is written to the file or dropped from it. To fix it, move the file aside, then restart the server; this discards every stored choice. If you restore that same file in place instead of moving it aside, a persona removed and added back with the same `name`, `credentials_file` and `working_directory` while it was unreadable gets its earlier choices again. |
559
673
 
560
674
  ### Start rules
561
675
 
@@ -596,7 +710,7 @@ grep -E 'reload-(preview|invalid|nothing-pending)' ~/.claude/channels/slack/serv
596
710
 
597
711
  ### Reading the preview
598
712
 
599
- The first line counts the personas added, removed, destructively modified, modified in place and with changed credentials, and the server-wide settings changed. Then there is one line per affected persona and per changed setting. For example:
713
+ The first line counts the personas added, removed, destructively modified, modified in place and with changed credentials, and the server-wide settings changed, the switch `allow_invited_channels` among them. A persona whose only changes are recorded ones (changes to the section not in force, see the table below) is not counted as modified. Then there is one line per affected persona, one per persona with a recorded change, and one per changed setting. For example:
600
714
 
601
715
  ```
602
716
  claude-slack-channel-bots: pending configuration change (written by the server)
@@ -611,6 +725,15 @@ server-wide setting port changed: once applied, it is recorded and takes effect
611
725
  server-wide setting claude_config_dir changed: inherited by "planner" (key=planner), "reviewer" (key=reviewer); takes effect at each one's next launch, which starts fresh (the conversation is not resumed), instance kept until then.
612
726
  ```
613
727
 
728
+ Made to the configuration in the declarative [Example](#example), a change that turns fungible mode on, gives `planner` an `invited.permission_prompts` and edits `reviewer`'s `channels`, which fungible mode doesn't read, previews like this:
729
+
730
+ ```
731
+ A configuration change is pending; nothing has been applied. personas: 0 added, 0 removed, 0 destructively modified, 1 modified in place, 0 with changed credentials; server-wide settings: 1 changed.
732
+ persona "planner" (key=planner): invited.permission_prompts changed: applied in place immediately, instance kept.
733
+ persona "reviewer" (key=reviewer): channels changed in the declarative section: recorded, with no effect until allow_invited_channels selects declarative mode.
734
+ server-wide setting allow_invited_channels changed: turns fungible mode on, applied in place at once, from the next event, tool call, prompt and notice, for "planner" (key=planner), "reviewer" (key=reviewer), "helpdesk" (key=helpdesk).
735
+ ```
736
+
614
737
  The preview describes the full effect of the change. What each kind of change does once confirmed is in [What a confirmation applies](#what-a-confirmation-applies).
615
738
 
616
739
  Paths in the preview are absolute: a `~` in `config.json` is shown expanded. In `server.log`, each preview line (everything after the `fingerprint:` line and the blank line) is prefixed `[slack] reload-preview:`, and the first one ends with where the preview is written: ` (preview in "<path of config.json.pending>")`.
@@ -624,12 +747,14 @@ Paths in the preview are absolute: a `~` in `config.json` is shown expanded. In
624
747
  | `claude_config_dir changed: …, which starts fresh (the conversation is not resumed)` | The persona's next launch uses the new config directory and starts a new conversation. The running instance is kept until then. |
625
748
  | `…; but at that launch it cannot come up: claude_config_dir cannot be resolved to a real path (<errno>)` | A warning after a changed `claude_config_dir`: the new directory can't be resolved, for example a symlink on its path points to nothing. On a changed top-level `claude_config_dir`, the warning names the inheriting personas it stops; on a `DESTRUCTIVE:` line that also changes the directory, it reads `but it cannot come up`. Confirming still applies the change: at the persona's next launch (at once, for a destructive change) it is held, its Slack connection closed, and it comes up once the directory resolves (`persona-config-dir-unresolvable` in `server.log`). Fix the directory, or the setting, before confirming. A directory that doesn't exist yet but whose parent does is fine. |
626
749
  | `… could not be checked.` | The server couldn't check whether an added persona can come up, whether a changed `claude_config_dir` can be resolved, or whether a persona is down because of its credentials. `server.log` has a `reload: cannot check …` line; report it as a bug. |
750
+ | `… changed in the declarative section: recorded, with no effect until allow_invited_channels selects declarative mode.` or `… changed in the fungible section: recorded, with no effect until allow_invited_channels selects fungible mode.` | A recorded change: the persona's section not in force changed (`channels` or `permission_prompts` in fungible mode, `invited` in declarative mode). The line names the persona and the changed fields. The change is recorded and has no effect until the switch selects that section. These lines come after every persona line and before the server-wide setting lines. A change made only of recorded changes previews as `no effective change`, followed by its recorded lines. |
751
+ | `server-wide setting allow_invited_channels changed: turns <mode> mode on, applied in place at once, from the next event, tool call, prompt and notice, for …` | The switch. The line names the mode it turns on (`fungible` or `declarative`) and every persona present in both configurations, or ends `; no persona is affected.` when there is none. Unlike other server-wide settings, it applies at once, with no restart, and every session is kept (see [Channel modes](#channel-modes)). |
627
752
  | `server-wide setting … inherited by …` | A changed top-level default. The line lists the personas that inherit it. If none does, it says `no persona inherits it, so no instance is affected`. |
628
753
  | `server-wide setting … changed: once applied, it is recorded and takes effect at the next server start after that.` | A setting such as `port` or `bind`. It doesn't take effect until the server starts after the change is applied. |
629
754
  | `server-wide setting … changed: once applied, it is recorded, and the CLI takes it from the record from then on (the running server does not use it).` | `stop_timeout` or `exit_timeout`. Only the CLI uses them: once the change is applied, the next `stop` or `clean_restart` uses the new value, with no restart needed. |
630
755
  | `server-wide setting … changed: once applied, it is recorded, the CLI takes it from the record from then on, and the running server uses it from its next start.` | `agent_director_call_timeout_ms`. Once the change is applied, the next `stop --stop-bots` or `clean_restart` uses the new value; the running server keeps its current value until it next starts. |
631
756
  | `INVALID: <error> Nothing will be applied.` | The edited `config.json` is invalid or missing. Fix the file; nothing is applied until it is valid. `server.log` shows a `reload-invalid` line. |
632
- | `… no effective change: …` | The edit changes nothing that runs (for example, whitespace, or a default written out). |
757
+ | `… no effective change: …` | The edit changes nothing that runs (for example, whitespace, a default written out, or only recorded changes, whose lines follow). |
633
758
 
634
759
  ### Confirming a change
635
760
 
@@ -639,7 +764,7 @@ To apply a pending change, rename `config.json.pending` to `config.json.apply` i
639
764
  cd "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}" && mv config.json.pending config.json.apply
640
765
  ```
641
766
 
642
- Read the preview first. The server picks the confirmation up at its next check, within about 5 seconds; a check still running an earlier apply delays it, so `reload-applied` can come minutes later. It deletes the confirmation and applies it without a restart; next-launch and server-wide settings are recorded and take effect later (see [What a confirmation applies](#what-a-confirmation-applies)).
767
+ Read the preview first. The server picks the confirmation up at its next check, within about 5 seconds; a check still running an earlier apply delays it, so `reload-applied` can come minutes later. It deletes the confirmation and applies it without a restart; next-launch and server-wide settings are recorded and take effect later, except the switch `allow_invited_channels`, which applies in place at once (see [What a confirmation applies](#what-a-confirmation-applies)).
643
768
 
644
769
  You can direct an agent to do the rename for you. Nothing has to be computed, copied or typed. A confirmation made while the server is stopped is processed at the first check after the next start.
645
770
 
@@ -652,10 +777,10 @@ grep -E 'reload-(applied|noop|invalid|stale-confirmation|record-write-failed)' "
652
777
  | Line | Meaning |
653
778
  |---|---|
654
779
  | `[slack] reload-applied: applied the confirmed configuration change without a restart (personas: …); the last-applied record "<path>" now holds it` | The change is applied, and `config.json.last-applied` holds it. |
655
- | `[slack] reload-noop: the confirmed configuration has no effective change, …` | Nothing that runs changed. The record was rewritten, and nothing is left pending. |
780
+ | `[slack] reload-noop: the confirmed configuration has no effective change, …` | Nothing that runs changed, as for a confirmation whose only changes are recorded ones (changes to the section not in force). The record was rewritten, and nothing is left pending. |
656
781
  | `[slack] reload-invalid: the confirmed configuration is invalid, so nothing is applied: <error>` | `config.json` was invalid or missing when you confirmed. The line carries the full error. Fix the file, then confirm the new preview. |
657
782
  | `[slack] reload-stale-confirmation: the confirmation "<path>" …; nothing is applied` | See [Stale confirmations](#stale-confirmations). |
658
- | `[slack] reload-record-write-failed: … the confirmed change is not applied and stays pending…` | `config.json.last-applied` or the retired-key record (`retired-keys.json`) couldn't be written, so nothing is applied; the line also says whether the keys the confirmation recorded as retired were removed again. Fix the state directory (permissions, free space), then confirm again. |
783
+ | `[slack] reload-record-write-failed: … the confirmed change is not applied and stays pending…` | `config.json.last-applied` or the retired-key record (`retired-keys.json`) couldn't be written, so nothing is applied; the line also says whether the keys the confirmation recorded as retired were removed again. The line can also name the stored-choice file (`channel-delivery.json`): a confirmation that brings up a persona whose stored choices were dropped earlier, by a write that failed, first writes that file; when it can't, nothing is applied, the retired-key record is not written, and the change stays pending. Fix the state directory (permissions, free space), then confirm again. |
659
784
 
660
785
  ### What a confirmation applies
661
786
 
@@ -663,14 +788,16 @@ Each row is one kind of change: what happens once you confirm it, and what happe
663
788
 
664
789
  | Change | Once confirmed | Live session |
665
790
  |---|---|---|
666
- | `channels`, `delivery`, `permission_prompts`, `dm.enabled` or `dm.contact` changes | Applied in place, immediately: from the next event or post. A changed DM contact is used for the next prompt. | Kept |
791
+ | `channels`, `delivery` or `permission_prompts` changes (in declarative mode), `invited.permission_prompts` changes (in fungible mode), or `dm.enabled` or `dm.contact` changes | Applied in place, immediately: from the next event or post. A changed DM contact is used for the next prompt. | Kept |
667
792
  | A credentials file's content changes (same path), such as a rotated token | Only that persona reconnects: the new connection opens, then the old one closes. If the new file can't be used or Slack refuses it, the old connection keeps running, `server.log` shows `persona-credentials-change-failed`, and the change stays pending. If Slack can't be reached, the old connection stays in use while the new one retries. A persona that is retrying (Slack unreachable, its working directory unusable, or held for its `claude_config_dir` by `persona-config-dir-unresolvable`) has no connection and retries with the new content. A persona down because of its credentials comes up on the confirmed change, with no restart; if its new file can't be used, it stays down with its usual line, such as `persona-credentials-invalid`, and nothing stays pending. | Kept |
668
793
  | `claude_config_dir` changes (the persona's own or inherited) | Recorded. The persona's next launch uses it and starts a new conversation on the same instance when the directory changed: the conversation is not resumed, nothing is deleted, and the old transcript stays in the old directory. | Kept until the next launch |
669
794
  | `stop_hook_bootstrap` changes (the persona's own or inherited) | Recorded. The persona's next launch uses it. | Kept |
670
795
  | A persona is removed | Torn down: its session is ended, with the result checked and retried, and its agent-director row is kept as the old life until agent-director's `expire` removes it, never resumed. Its posted permission prompts stay in Slack, and clicking one has no effect. Until its old instance has ended, no persona is brought up in its working directory (see "A persona waits on an old instance in its working directory" in [Troubleshooting](#troubleshooting)). Previewed `DESTRUCTIVE:`. | Retired: never resumed |
671
796
  | A persona's `credentials_file` path or `working_directory` changes | Torn down as a removal is (its row kept, never resumed), then brought up fresh from its new entry on the same instance, with no restart: a new conversation, and the new credentials file is read. The new conversation starts only once the old instance has ended, and any other persona in the old working directory waits until then too (see "A persona waits on an old instance in its working directory" in [Troubleshooting](#troubleshooting)). Previewed `DESTRUCTIVE:`. See [Destructive changes](#destructive-changes). | Retired: never resumed |
672
797
  | A persona's `name` changes | A removal plus an addition: the name sets the key. The old persona is torn down as a removal is (its row kept until agent-director's `expire` removes it, never resumed), and the new one is brought up fresh once the old one's instance has ended (see "A persona waits on an old instance in its working directory" in [Troubleshooting](#troubleshooting)). The removal half is previewed `DESTRUCTIVE:` (`DESTRUCTIVE: persona "<old name>" … is removed`), followed by an `… is added` line for the new name. | Retired: never resumed |
673
- | A server-wide setting changes, such as `port` | Recorded. The running server keeps its current value, and the next server start uses the recorded one. `stop_timeout` and `exit_timeout` are an exception: only the CLI uses them, and it takes them from the record at once, so the next `stop` or `clean_restart` uses them. `agent_director_call_timeout_ms` is the other: the CLI takes it from the record at once, and the running server uses it from its next start. | Not affected |
798
+ | The switch `allow_invited_channels` changes, turning fungible mode on or off | Applied in place, at once: from the next event, tool call, prompt and notice, with no restart. Each persona serves the channels of the mode turned on (see [Channel modes](#channel-modes)). While fungible mode is on, stored channel-delivery choices apply; listed `delivery` values are not carried over, so each channel is at `mentions` unless a stored choice applies. | Kept |
799
+ | `channels` or `permission_prompts` changes in fungible mode, or `invited` changes in declarative mode: the section not in force | Recorded, with no effect until the switch selects that section. A confirmation whose only changes are recorded ones logs `reload-noop`. | Kept |
800
+ | A server-wide setting changes, such as `port` | Recorded. The running server keeps its current value, and the next server start uses the recorded one. `allow_invited_channels` is the exception: it is applied in place at once (see the switch's row above). `stop_timeout` and `exit_timeout` differ too: only the CLI uses them, and it takes them from the record at once, so the next `stop` or `clean_restart` uses them. `agent_director_call_timeout_ms` differs as well: the CLI takes it from the record at once, and the running server uses it from its next start. | Not affected |
674
801
  | A persona is added | Brought up exactly as at start, including the storage check (`jsonl-non-persistent`, see [Startup errors](#startup-errors)). If its credentials file or working directory is bad, it logs the same lines as at start and never affects running personas. See "A persona doesn't come up or doesn't answer" in [Troubleshooting](#troubleshooting). | New session |
675
802
 
676
803
  - **Inherited defaults.** A changed top-level `claude_config_dir` or `stop_hook_bootstrap` takes effect at each inheriting persona's next launch. The preview lists those personas; a persona that sets its own value isn't affected.
@@ -680,7 +807,7 @@ Each row is one kind of change: what happens once you confirm it, and what happe
680
807
 
681
808
  ### When next-launch and server-wide changes take effect
682
809
 
683
- A persona's next launch happens when the bot dies (a crash or a failed health check), at a `clean_restart`, at `stop --stop-bots` then `start`, or after a host reboot. A plain `stop` and `start` reconnects to the running instance, which is not a launch. Server-wide settings take effect at the next server start. The CLI takes `stop_timeout`, `exit_timeout` and `agent_director_call_timeout_ms` from the record at once; see the table.
810
+ A persona's next launch happens when the bot dies (a crash or a failed health check), at a `clean_restart`, at `stop --stop-bots` then `start`, or after a host reboot. A plain `stop` and `start` reconnects to the running instance, which is not a launch. Server-wide settings take effect at the next server start, except the switch `allow_invited_channels`: it takes effect when the change is confirmed, and needs no `clean_restart`. The CLI takes `stop_timeout`, `exit_timeout` and `agent_director_call_timeout_ms` from the record at once; see the table.
684
811
 
685
812
  To make them take effect sooner, wait for the `reload-applied` line, then run `claude-slack-channel-bots clean_restart`. It reads the bot list from the record before it stops the server, so if you run it earlier it can work from the old record. The server comes back on the record, and every bot is relaunched. Conversations resume, except that a changed `claude_config_dir` starts that persona fresh.
686
813
 
@@ -700,7 +827,7 @@ To fix it, wait for the server to write `config.json.pending` again (within abou
700
827
 
701
828
  ### Size limit
702
829
 
703
- `config.json`, the files beside it and each credentials file are read only up to 64 KiB; a larger file is treated as unreadable. The retired-key record (`retired-keys.json`) has no size cap: only the server writes it. If a very large change makes `config.json.pending` itself larger, its rename is refused as stale, so split the change into smaller edits.
830
+ `config.json`, the files beside it and each credentials file are read only up to 64 KiB; a larger file is treated as unreadable. The retired-key record (`retired-keys.json`) and the stored-choice file (`channel-delivery.json`) have no size cap: only the server writes them. If a very large change makes `config.json.pending` itself larger, its rename is refused as stale, so split the change into smaller edits.
704
831
 
705
832
  ### No reload command
706
833
 
@@ -1043,6 +1170,8 @@ To install the version of CSCB sitting in your working copy (so the globally-lin
1043
1170
 
1044
1171
  `bun install -g .` (and the equivalent `bun install -g <local-path>`) is broken on Bun 1.3.13 — it inserts an invalid empty-string dependency key into `~/.bun/install/global/package.json` and then any subsequent global op fails with `error: Package "@" has a dependency loop` (upstream: [oven-sh/bun#24207](https://github.com/oven-sh/bun/issues/24207)). The script uses `bun add -g file:<abs-path>` instead, and pre-emptively strips any empty-string entry a prior `bun install -g .` may have already left behind.
1045
1172
 
1173
+ Bun runs this install's postinstall in the worktree itself, a git checkout, so the postinstall writes the skeleton config files but leaves `~/.claude/skills/` alone: it makes no new link into the worktree, and a link already there keeps pointing where it did. While this install is in place, though, the global package `~/.bun/install/global/node_modules/claude-slack-channel-bots` resolves into your worktree, so a `debug-slack-channel-bots` link into it, as an install of the published package makes, reads the worktree's copy of the skill until you reinstall the published package ([Quick Start](#quick-start)).
1174
+
1046
1175
  ### Direct invocation for development
1047
1176
 
1048
1177
  Skip the CLI and run the server directly with Bun for development or debugging:
@@ -1081,13 +1210,25 @@ Each MCP endpoint exposes the following tools to the connected Claude Code sessi
1081
1210
 
1082
1211
  | Tool | Description |
1083
1212
  |---|---|
1084
- | `reply` | Send a message to one of the persona's configured channels or, when the persona's `dm.enabled` is `true`, to a DM conversation ID (`D…`) or a Slack user ID (`U…`/`W…`). A user ID opens a DM with that user and posts there as the persona; the result names the DM conversation ID to use for later calls. Splits long text into several messages by the server-wide `reply_chunk_limit` and `reply_chunk_mode` settings. With `message_id` (the message being answered), removes this persona's acknowledgement reaction from that message. Supports file attachments. |
1085
- | `react` | Add an emoji reaction to a Slack message in a configured channel or, with `dm.enabled` `true`, a DM conversation (`D…`). |
1086
- | `edit_message` | Edit a previously sent message (bot's own messages only) in a configured channel or, with `dm.enabled` `true`, a DM conversation (`D…`). |
1087
- | `fetch_messages` | Fetch message history from a configured channel, a DM conversation (`D…`, with `dm.enabled` `true`) or a thread in either. Returns oldest-first. |
1088
- | `download_attachment` | Download attachments from a Slack message in a configured channel or, with `dm.enabled` `true`, a DM conversation (`D…`). Saves files to `STATE_DIR/inbox/`. Returns local file paths. Only files hosted by Slack are downloaded; external files are refused. |
1213
+ | `reply` | Send a message to a channel (in declarative mode one of the persona's configured channels; in fungible mode any channel ID) or, when the persona's `dm.enabled` is `true`, to a DM conversation ID (`D…`) or a Slack user ID (`U…`/`W…`). A user ID opens a DM with that user and posts there as the persona; the result names the DM conversation ID to use for later calls. Splits long text into several messages by the server-wide `reply_chunk_limit` and `reply_chunk_mode` settings. With `message_id` (the message being answered), removes this persona's acknowledgement reaction from that message. Supports file attachments. |
1214
+ | `react` | Add an emoji reaction to a Slack message in a channel (a configured channel in declarative mode; any channel ID in fungible mode) or, with `dm.enabled` `true`, a DM conversation (`D…`). |
1215
+ | `edit_message` | Edit a previously sent message (bot's own messages only) in a channel (a configured channel in declarative mode; any channel ID in fungible mode) or, with `dm.enabled` `true`, a DM conversation (`D…`). |
1216
+ | `fetch_messages` | Fetch message history from a channel (a configured channel in declarative mode; any channel ID in fungible mode), a DM conversation (`D…`, with `dm.enabled` `true`) or a thread in either. Returns oldest-first. |
1217
+ | `download_attachment` | Download attachments from a Slack message in a channel (a configured channel in declarative mode; any channel ID in fungible mode) or, with `dm.enabled` `true`, a DM conversation (`D…`). Saves files to `STATE_DIR/inbox/`. Returns local file paths. Only files hosted by Slack are downloaded; external files are refused. |
1218
+ | `set_channel_delivery` | Set the persona's [channel delivery](#channel-delivery-in-fungible-mode) for one channel. Two inputs: `channel`, a channel ID, and `delivery`, `mentions` or `all`. The persona's agent calls it when someone in that channel asks. It works only in fungible mode: every call is refused in declarative mode, and while `channel-delivery.json` is unreadable. It accepts only a channel the persona has heard a message from in fungible mode since it came up, or one it holds a stored choice for. It makes no Slack call. The choice persists in `channel-delivery.json` and applies from the next message in that channel; the result names the persona's channel delivery there, and says so when the loop guard holds the channel at `mentions`. Each accepted call logs one `persona-channel-delivery-set` line. It changes no configuration and applies no reload. |
1219
+
1220
+ Only `reply` takes a user ID; the other tools need a channel or DM conversation ID. When `dm.enabled` ([Persona fields](#persona-fields)) is `false`, the persona has no DM target in either mode: no tool can post in, read or open a DM.
1221
+
1222
+ - **In declarative mode,** a tool call with any other target, a channel the persona isn't configured into included, is refused with a tool error naming the persona and the target, and nothing is sent to Slack.
1223
+ - **In fungible mode,** a target that is neither a channel ID nor an allowed DM target is refused before any Slack call, with a tool error naming the persona and the target. Any other channel target goes to Slack, which decides: Slack's refusal, for example `not_in_channel` for a channel the persona's app is not in, reaches the persona as a tool error naming the persona, the channel and Slack's error code.
1089
1224
 
1090
- Only `reply` takes a user ID; the other tools need a channel or DM conversation ID. When `dm.enabled` ([Persona fields](#persona-fields)) is `false`, the persona has no DM target: no tool can post in, read or open a DM. A tool call with any other target is refused with a tool error naming the persona and the target, and nothing is sent to Slack.
1225
+ Known limits of the fungible-mode targets:
1226
+
1227
+ - CSCB makes no Slack call to learn whether a channel holds the persona's app, is externally shared or is a group DM. So in fungible mode it refuses no post, read, reaction or edit in an externally shared channel that the app belongs to, although fungible mode never delivers a message from such a channel.
1228
+ - The same holds in a group DM the app belongs to, except that Slack refuses reads there: the app has no `mpim:history` scope.
1229
+ - In fungible mode, `edit_message` on the persona's own earlier post in a channel its Slack app is not in, for example after the app was removed from the channel, is sent to Slack, and Slack accepts it.
1230
+
1231
+ On a host whose Claude Code permission settings don't allow the server's tools as a group (an allow rule such as `mcp__slack-channel-router`, rather than one rule per tool such as `mcp__slack-channel-router__reply`), the first `set_channel_delivery` call raises a permission prompt, in either mode (see [Permission Relay](#permission-relay)).
1091
1232
 
1092
1233
  ---
1093
1234
 
@@ -1151,12 +1292,18 @@ The server fires scheduled prompts into personas once per minute, reading them f
1151
1292
 
1152
1293
  ### The crontable
1153
1294
 
1154
- Schedules live in the crontable file at `cron_table_path` (default `<config dir>/crontab`, where `<config dir>` is the directory of your loaded `config.json`; override it with the `cron_table_path` key in `config.json`). The server creates the file on first boot if it is absent, with a self-documenting comment header describing the line format. See [Crontable format](#crontable-format) below for the full reference. With the default config location the crontable is at `~/.claude/channels/slack/crontab`:
1295
+ Schedules live in the crontable file at `cron_table_path`. Without that key the crontable is at `~/.config/cscb/crontab` (`$XDG_CONFIG_HOME/cscb/crontab` when `XDG_CONFIG_HOME` is an absolute path), outside `~/.claude`, so a bot appending a schedule doesn't trigger Claude Code's approval prompt for writes under `~/.claude`. With `SLACK_STATE_DIR` set, the default is `crontab` beside `config.json` in that directory. Set `cron_table_path` in `config.json` to put it anywhere else (see [Server-wide settings](#server-wide-settings)).
1155
1296
 
1156
1297
  ```sh
1157
- cat ~/.claude/channels/slack/crontab
1298
+ cat ~/.config/cscb/crontab
1158
1299
  ```
1159
1300
 
1301
+ At start the server creates the default crontable's directory. It creates the file on first boot if it is absent, with a self-documenting comment header describing the line format. See [Crontable format](#crontable-format) below for the full reference. For a `cron_table_path` you set, the directory must already exist.
1302
+
1303
+ If the server starts with the default and finds a crontable at `crontab` beside `config.json` (`~/.claude/channels/slack/crontab`), it moves that file to the default location, its lines unchanged. It leaves a symbolic link to the new file at the old path, so a resumed session whose `CSCB_CRONTABLE_PATH` still names the old path appends to the same file. A `prompts/` directory beside the old crontable moves with it and leaves a link at its old name, so relative prompt paths keep naming the same files.
1304
+
1305
+ If both paths hold a crontable, the server uses the one at the default location and logs a warning. Nothing is moved when `config.json` sets `cron_table_path`, even to `~/.config/cscb/crontab`. The `debug-slack-channel-bots` skill covers each line the move logs.
1306
+
1160
1307
  ### Crontable format
1161
1308
 
1162
1309
  On first boot the server auto-creates the crontable with this self-documenting header:
@@ -1193,7 +1340,7 @@ On first boot the server auto-creates the crontable with this self-documenting h
1193
1340
  # Example (every day at 09:00, run grooming-tick.md, target two personas):
1194
1341
  # 0 9 * * * /home/horde/prompts/grooming-tick.md planner,reviewer
1195
1342
  #
1196
- # Example (every hour on the hour, run standup.md, target all bots):
1343
+ # Example (every hour, run standup.md, no persona list — NOT delivered yet):
1197
1344
  # 0 * * * * /home/horde/prompts/standup.md
1198
1345
  ```
1199
1346
 
@@ -1208,7 +1355,7 @@ Rules:
1208
1355
  - **Exactly 5 cron fields** (minute hour day-of-month month day-of-week). Croner's 6-field (seconds-precision) and `@macro` forms are **not** supported.
1209
1356
  - **Name each persona by its name or its key.** A persona named twice — by name, by key, or both — receives the prompt once.
1210
1357
  - **Use the key for a name with whitespace or a comma.** Such a name cannot be written in a crontable line.
1211
- - **Omit the persona list to target ALL bots** — the omission itself is the all-bots form. (All-bots delivery is currently deferred — see [Delivery semantics](#delivery-semantics).)
1358
+ - **Give every line a persona list.** A line with no persona list is the all-bots form, and all-bots delivery is not built yet: the line loads, but each load logs a `warn` naming it and each fire is dropped (see [The cron log](#the-cron-log)).
1212
1359
  - **No `*` wildcard in the persona position.** A literal `*` where a persona belongs is a parse error, not "all personas".
1213
1360
  - **Prompt paths cannot contain spaces.** A path with spaces is unrepresentable; the extra tokens make the line a parse error and it is skipped.
1214
1361
  - **`#` comments and blank lines are allowed** and ignored.
@@ -1224,14 +1371,14 @@ Here the 6th field (`1`) is taken as the prompt path and the real path (`~/promp
1224
1371
 
1225
1372
  **Upgrading a crontable that names channels.** The server never rewrites an existing crontable, so channel-ID targets are not converted. Once your config defines personas, a Slack channel ID no longer names a bot, so edit each such line to a persona name or key; until you do, the target logs `unknown-persona`.
1226
1373
 
1227
- An existing crontable also keeps its old comment header, which still tells bots to write channel IDs. Replace that comment block by hand with the header shown above.
1374
+ An existing crontable also keeps its old comment header, which still tells bots to write channel IDs and to leave out the target list to reach all bots. Replace that comment block by hand with the header shown above. This section is the reference for the line format, whatever a crontable's own header says.
1228
1375
 
1229
1376
  ### Path resolution
1230
1377
 
1231
1378
  The prompt-file path resolves as follows:
1232
1379
 
1233
1380
  - A leading `~` expands to the home directory.
1234
- - A **relative** path resolves against the **crontable's own directory** — not `$HOME`. This is a deliberate divergence from system cron's convention, so you can keep a `prompts/` directory alongside the crontable and reference it as `prompts/standup.md`.
1381
+ - A **relative** path resolves against the **crontable's own directory** — not `$HOME`. This is a deliberate divergence from system cron's convention, so you can keep a `prompts/` directory alongside the crontable and reference it as `prompts/standup.md`. With the default location that directory is `~/.config/cscb/prompts/`.
1235
1382
  - An **absolute** path is used as-is.
1236
1383
 
1237
1384
  ### How fires appear
@@ -1240,7 +1387,9 @@ A scheduled fire reaches only the target persona's instance, as an `/interject`
1240
1387
 
1241
1388
  ### The cron log
1242
1389
 
1243
- Every fire outcome is recorded in the cron log at `cron_log_path` (default `<config dir>/cron.log`; override with the `cron_log_path` key). Each attempt writes one line per persona targeted (showing the target as first written in the crontable), plus a per-fire summary line carrying `delivered=N failed=M` counts. The log is plain text, so `grep no-session cron.log` yields readable lines.
1390
+ Every fire outcome is recorded in the cron log at `cron_log_path` (default `<config dir>/cron.log`, beside `config.json` wherever the crontable is; override with the `cron_log_path` key). Each attempt writes one line per persona targeted (showing the target as first written in the crontable), plus a per-fire summary line carrying `delivered=N failed=M` counts. The log is plain text, so `grep no-session cron.log` yields readable lines.
1391
+
1392
+ By default the log only grows. To cap it, set `cron_log_max_bytes` in `config.json`: once the log passes that many bytes, its oldest whole lines are dropped, so the oldest fires disappear from it while the newest stay. There is no rotated copy; copy the log elsewhere if you need a longer history. Don't move or delete it yourself: the server keeps writing to the old file until that file passes the cap. A changed cap takes effect at the next server start.
1244
1393
 
1245
1394
  The `outcome` field of each line is one of these classes:
1246
1395
 
@@ -1252,18 +1401,32 @@ The `outcome` field of each line is one of these classes:
1252
1401
  | `prompt-missing` | The prompt file did not exist at fire time. | Create the file or correct its path in the crontable. |
1253
1402
  | `prompt-unreadable` | The prompt file existed but could not be read (see the `errno`). | Fix file permissions or the path. |
1254
1403
  | `prompt-oversize` | The prompt exceeds the 32KB `/interject` cap and was skipped, never truncated. | Shorten the prompt file. |
1255
- | `parse-error` | The crontable line could not be parsed. | Fix the line — see the crontable header for the format. |
1404
+ | `parse-error` | The crontable line could not be parsed. | Fix the line — see [Crontable format](#crontable-format). |
1256
1405
  | `http-error` | The localhost POST hit an unexpected HTTP status or a network failure. | Check that the server is listening on loopback (see the `bind` note below) and inspect the `errno`/`status` in the line. |
1257
- | `fanout-deferred` | A line with no persona list (all-bots) was matched but not delivered. | None — all-bots fan-out is not yet enabled; give the line an explicit persona to deliver it today. |
1406
+ | `fanout-deferred` | A line with no persona list (all-bots) was matched but not delivered. | Add a persona list to the line — all-bots delivery is not built yet. |
1407
+
1408
+ Each load of the crontable, at start and after each edit, writes one line with its counts. At start it is `scheduler started, N schedules loaded, M undeliverable (no persona list)`; after an edit it is `crontable reloaded, N schedules, M undeliverable (no persona list)`. `M` counts the lines that will never be delivered, and it is written even when it is 0.
1409
+
1410
+ Each load also writes one `warn` line for each line with no persona list, naming its line number in the file and its prompt path:
1411
+
1412
+ ```text
1413
+ 2026-10-09T15:12:00.002Z cscb-cron:grooming - warn line=34 prompt=/home/horde/prompts/grooming.md undeliverable: this crontable line names no personas, so it will never be delivered in this version …
1414
+ ```
1415
+
1416
+ Add a persona list to that line. The warning comes back at every start and every edit until you do; an unchanged crontable is not reloaded, so it is not repeated each minute. To list them in the cron log at its default location:
1417
+
1418
+ ```sh
1419
+ grep undeliverable ~/.claude/channels/slack/cron.log
1420
+ ```
1258
1421
 
1259
1422
  ### Delivery semantics
1260
1423
 
1261
1424
  - **No retry.** A failed fire is logged and dropped — never queued or re-sent. A persona that is not up, or has no live session, fails every fire until it is up and its session is running again; the server does not queue the missed prompts.
1262
- - **Missed fires are skipped, not caught up.** While the server is down, no scheduled prompts fire, and they are not replayed on restart. The `scheduler started, N schedules loaded` line in the cron log marks when scheduling resumed, bounding the outage window.
1425
+ - **Missed fires are skipped, not caught up.** While the server is down, no scheduled prompts fire, and they are not replayed on restart. The `scheduler started, N schedules loaded, M undeliverable (no persona list)` line in the cron log marks when scheduling resumed, bounding the outage window.
1263
1426
  - **Edits take effect within a minute — no restart.** The scheduler checks the crontable fresh on every tick, so an edit by hand or a line appended by a bot starts (or stops) firing within about a minute. The server is never restarted for a schedule change.
1264
1427
  - **Deleting the crontable stops all schedules.** Nothing fires from that moment, a WARN appears in the cron log, and the server re-creates the file empty (with its header) within about two minutes — detection and re-creation happen on separate once-a-minute passes, so the re-create lands up to two tick boundaries after the deletion. Add lines back and they schedule on the next check.
1265
1428
  - **Server-local time.** Cron expressions are evaluated in the server's local timezone.
1266
- - **Lines without a persona list are deferred.** A line with no persona list is currently matched but logged `fanout-deferred` and not delivered. Give a line an explicit persona to have it fire.
1429
+ - **A line without a persona list is never delivered.** Each load logs a `warn` naming it and counts it undeliverable; each time it matches, it is logged `fanout-deferred` and dropped. Give the line a persona list to have it fire.
1267
1430
  - **`bind` must include loopback.** The scheduler delivers via `127.0.0.1`, so a `bind` set to a single non-loopback interface makes every fire fail with `http-error`. Use the default `127.0.0.1` or `0.0.0.0`.
1268
1431
 
1269
1432
  ### Bot self-scheduling
@@ -1292,10 +1455,12 @@ Flow:
1292
1455
 
1293
1456
  1. agent-director moves the spawn into `check_permission` state when Claude requests a tool permission.
1294
1457
  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).
1295
- 3. For each new spawn, `client.get(...)` returns the open permission request (tool name, tool input and an opaque `request_token`). CSCB identifies the persona that owns the spawn from its `persona` label and posts the Block Kit prompt to the persona's destination as that persona: its `permission_prompts` channel, or, for `"dm"`, a DM from the persona's app to its `dm.contact`. The server opens that DM if none exists yet, so a DM-only persona's first prompt is delivered too. Each persona follows its own setting.
1458
+ 3. For each new spawn, `client.get(...)` returns the open permission request (tool name, tool input and an opaque `request_token`). CSCB identifies the persona that owns the spawn from its `persona` label and posts the Block Kit prompt to the persona's destination as that persona: its destination channel (its `permission_prompts` in declarative mode, its `invited.permission_prompts` in fungible mode), or, for a `"dm"` destination, a DM from the persona's app to its `dm.contact`. The server opens that DM if none exists yet, so a DM-only persona's first prompt is delivered too. Each persona follows its own setting.
1296
1459
  4. The operator clicks Allow / Deny in Slack, in the channel or the DM; the buttons work the same in both. CSCB resolves the click through the same persona: it calls `client.decide({ claude_instance_id, decision, request_token })` and, as that persona, updates the message to "*Permission* — Allowed" or "*Permission* — Denied by operator".
1297
1460
  5. If a tracked prompt closes for any reason other than a Slack click, the next poller tick replaces the buttons with the verdict: "⏱ *Permission* — Timed out", "🪦 *Permission* — Session ended", "*Permission* — Allowed", "*Permission* — Denied by operator", or "*Permission* — Denied (closed)" when the reason is unknown.
1298
1461
 
1462
+ The prompt names the tool and shows its input: Bash's command, Edit's or Write's file path, or for any other tool the input as JSON, up to 500 characters. An input too long for Slack's 3000-character limit on a message section is cut to fit and followed by `… (truncated, <shown> of <total> chars shown)`; the rest is not shown in Slack.
1463
+
1299
1464
  While a persona is not up (broken or retrying), its instance keeps running but its permission prompts are not posted and its already-posted prompts are left as they are. They appear, or get their verdict, on the first poll after the persona comes up.
1300
1465
 
1301
1466
  If Slack refuses a post to the persona's destination, see "A permission prompt or notice doesn't arrive" under [Troubleshooting](#troubleshooting).
@@ -1316,7 +1481,7 @@ The template also pre-allows each bot to read its own persistent-memory director
1316
1481
 
1317
1482
  ## How a persona receives messages
1318
1483
 
1319
- Every message reaches a persona's Claude instance as its text wrapped in a `<channel source="slack-channel-router" …>` tag. A message from Slack carries a `via` attribute that says how it reached the persona. An injected message (a scheduled prompt or an `/interject` message) carries no `via`.
1484
+ Every message reaches a persona's Claude instance as its text wrapped in a `<channel source="slack-channel-router" …>` tag. A message from Slack carries a `via` attribute that says how it reached the persona. An injected message (a scheduled prompt or an `/interject` message) carries no `via`. Which channels a persona serves, and at which delivery, depends on the channel mode (see [Channel modes](#channel-modes)).
1320
1485
 
1321
1486
  A message from Slack carries these tag attributes:
1322
1487
 
@@ -1336,10 +1501,10 @@ Each way a message reaches a persona:
1336
1501
  | Kind | How it reaches the persona | Tag attributes | `via` | Reminder |
1337
1502
  |---|---|---|---|---|
1338
1503
  | Direct message | A DM to the persona's own Slack app, when its `dm.enabled` is `true`, whatever the text mentions. With `dm.enabled` `false` the server drops it and logs one `persona-dm-dropped` line. A group DM is never delivered. | Slack attributes; `chat_id` is the DM conversation ID | `dm` | Yes, direct-message wording |
1339
- | Direct @mention | A message that @mentions the persona in a channel listed in its `channels`, with `delivery: mentions` or `delivery: all`. | Slack attributes | `mention` | Yes, direct-mention wording |
1340
- | `@here` / `@channel` broadcast | A message that uses `@here` or `@channel` in a channel listed in the persona's `channels`, with either `delivery`. `@everyone` and user-group mentions are not broadcasts. | Slack attributes | `broadcast` | Yes, broadcast wording |
1341
- | Every message, shared channel | A message that neither @mentions the persona nor broadcasts, in a channel listed in its `channels` with `delivery: all`, when at least one other persona in the applied configuration also has `delivery: all` for that channel, whether or not that persona is up. | Slack attributes | `receive_all_shared` | Yes, shared-channel wording |
1342
- | Every message, this persona alone | A message that neither @mentions the persona nor broadcasts, in a channel listed in its `channels` with `delivery: all`, when no other persona in the applied configuration has `delivery: all` for that channel. | Slack attributes | `receive_all` | Yes, only-you wording |
1504
+ | Direct @mention | A message that @mentions the persona. In declarative mode: in a channel listed in its `channels`, with `delivery: mentions` or `delivery: all`. In fungible mode: in a public or private channel its app is in that is not externally shared, at either channel delivery. | Slack attributes | `mention` | Yes, direct-mention wording |
1505
+ | `@here` / `@channel` broadcast | A message that uses `@here` or `@channel`. In declarative mode: in a channel listed in the persona's `channels`, with either `delivery`. In fungible mode: in a public or private channel its app is in that is not externally shared, at either channel delivery. `@everyone` and user-group mentions are not broadcasts. | Slack attributes | `broadcast` | Yes, broadcast wording |
1506
+ | Every message, shared channel | A message that neither @mentions the persona nor broadcasts. In declarative mode: in a channel listed in its `channels` with `delivery: all`, when at least one other persona in the applied configuration also has `delivery: all` for that channel, whether or not that persona is up. In fungible mode: in a public or private channel its app is in that is not externally shared, where its channel delivery is `all` (a stored choice, after the loop guard; see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode)), when at least one other persona in the applied configuration has a channel delivery of `all` for that channel by the same rule, whether or not that persona's app remains in the channel. | Slack attributes | `receive_all_shared` | Yes, shared-channel wording |
1507
+ | Every message, this persona alone | A message that neither @mentions the persona nor broadcasts. In declarative mode: in a channel listed in its `channels` with `delivery: all`, when no other persona in the applied configuration has `delivery: all` for that channel. In fungible mode: in a public or private channel its app is in that is not externally shared, where its channel delivery is `all`, when no other persona in the applied configuration has a channel delivery of `all` for that channel. | Slack attributes | `receive_all` | Yes, only-you wording |
1343
1508
  | Scheduled prompt | A crontable line naming the persona fires, and the scheduler posts it to `/interject` for that persona only (see [Scheduled Prompts](#scheduled-prompts-cscb_cron)). | Only `user` = `cscb-cron:<prompt file name without its extension>` (`cscb-cron:standup` for `standup.md`) and `ts` = the server clock in seconds. No `chat_id`, `message_id` or `via`. | none | Never |
1344
1509
  | `/interject` message | A localhost POST to `/interject` addressed by `persona` (see [Interject](#interject)); it reaches only that persona. | Only `user` = the request's `sender` (default `interject`) and `ts`, as for a scheduled prompt. No `chat_id`, `message_id` or `via`. | none | Never |
1345
1510
 
@@ -1348,12 +1513,31 @@ A "Yes" in the Reminder column means the [Slack Reply Guard](#slack-reply-guard-
1348
1513
  - **The first applicable `via` wins,** in the order `dm`, `mention`, `broadcast`, `receive_all_shared`, `receive_all`. A message that @mentions the persona in a channel it receives in full arrives as `mention`.
1349
1514
  - **The persona's own @mention is removed from the text.** Mentions of other personas and `@here` / `@channel` stay.
1350
1515
  - **Every author counts.** Posts by people, bots, integrations and other personas all arrive by these rules. Each persona posts as its own Slack app, so another persona's post reaches it like anyone else's.
1351
- - **Anyone in the workspace can reach a persona,** in a channel it is configured into by that channel's `delivery` setting, and by DM when its `dm.enabled` is `true`. There is no approval step.
1516
+ - **Anyone in the workspace can reach a persona,** in both modes: in declarative mode in its listed channels, by each channel's `delivery`; in fungible mode in every channel its app is invited to that it serves, by its channel delivery there; and in both modes by DM when its `dm.enabled` is `true`. There is no approval step. In fungible mode an invite changes who that is (see [Who can reach a persona in fungible mode](#who-can-reach-a-persona-in-fungible-mode)).
1352
1517
  - **A persona never receives its own posts.**
1353
1518
  - **Each message arrives at most once per persona,** even though Slack sends a channel @mention twice. A Slack redelivery more than 10 minutes after the first, or after a server restart, arrives again.
1519
+ - **In fungible mode, an @mention is delivered from its `message` event.** Of the two events Slack sends for a channel @mention, the `app_mention` event decides nothing and the `message` event carrying the same mention decides, so each @mention is delivered once, whichever event arrives first. An edit that adds a mention to an earlier message sends no new `message` event, so in fungible mode it is not delivered.
1354
1520
  - **A message without `via` is an injected prompt.** Its `user` label is free-form, so an `/interject` sender label, even one starting with `cscb-cron:`, cannot make it look like a Slack message.
1355
1521
  - **When the reminder is off.** A persona gets no reminder when its effective `stop_hook_bootstrap` was `false` at its last launch, when it has no `claude_config_dir` (its own or the top-level one), when that directory resolves to `~/.claude`, or when `jq` is not installed. See [Slack Reply Guard](#slack-reply-guard-stop-hook).
1356
1522
 
1523
+ ### Who can reach a persona in fungible mode
1524
+
1525
+ Read this before turning fungible mode on. Each persona's Claude worker runs with tool access on the host, and in fungible mode the workspace's members, not the operator, choose the channels it serves.
1526
+
1527
+ - **An invite changes a persona's audience.** Any workspace member can point a persona's worker at any channel they are in by inviting the persona's app there. This includes private channels, which the operator may not be able to see.
1528
+ - **Anyone who can reach the persona can switch a channel to `all`.** In any channel it serves, or by DM, anyone can ask the persona to switch any channel it serves to `all`, including a channel they are not in. From then on, every message in that channel reaches the worker.
1529
+ - **`fetch_messages` reads every channel the app is in.** The persona's `fetch_messages` can read the history of every channel its app is a member of, so content can be carried from one channel to another.
1530
+ - **Slack Connect channels are never served.** An externally shared channel is never served in fungible mode; serve it in declarative mode, with the channel listed.
1531
+ - **Enterprise Grid: Slack's flag decides.** A channel shared between workspaces of one Enterprise Grid organization is served when Slack marks it as not externally shared, and then members of every workspace sharing it can reach the persona.
1532
+
1533
+ The audit lines in `server.log` are `persona-invited-channel`, logged the first time a persona hears a channel in fungible mode (naming the channel, `public` or `private`, and its channel delivery), and `persona-channel-delivery-set`, logged for each stored `set_channel_delivery` choice. The debugging skill explains both ([`persona-invited-channel`](skills/debug-slack-channel-bots/SKILL.md#persona-invited-channel), [`persona-channel-delivery-set`](skills/debug-slack-channel-bots/SKILL.md#persona-channel-delivery-set)). To list them:
1534
+
1535
+ ```sh
1536
+ grep -E 'persona-(invited-channel|channel-delivery-set)' ~/.claude/channels/slack/server.log*
1537
+ ```
1538
+
1539
+ A persona logs a channel's `persona-invited-channel` line only once until the server restarts or the persona is brought up fresh, so on a long-running server, log rotation can discard a channel's only audit line (see [Server log rotation](#server-log-rotation)). After a restart, the first event from each channel a persona hears in fungible mode logs its line again.
1540
+
1357
1541
  ---
1358
1542
 
1359
1543
  ## Slack Reply Guard (Stop hook)
@@ -1507,7 +1691,7 @@ grep -E '\(key=<key>\)|persona=<key>\b' ~/.claude/channels/slack/server.log
1507
1691
 
1508
1692
  While a persona is down, its Claude instance keeps running and keeps its history, but the server doesn't serve it until the persona is up.
1509
1693
 
1510
- The `debug-slack-channel-bots` skill has an entry for every persona log class, every `config.json` rejection and each recovery step. It ships in the package at `skills/debug-slack-channel-bots/SKILL.md`, and postinstall links it into `~/.claude/skills/debug-slack-channel-bots`; a copied directory or file already at that path is left in place, and postinstall logs `skipped: <path> (not a link; …)` — remove it and re-run postinstall to get the link. Invoke `/debug-slack-channel-bots` from Claude Code.
1694
+ The `debug-slack-channel-bots` skill has an entry for every persona log class, every `config.json` rejection and each recovery step. It ships in the package at `skills/debug-slack-channel-bots/SKILL.md`, and the postinstall of an installed package (a global install) links it into `~/.claude/skills/debug-slack-channel-bots`; a copied directory or file already at that path is left in place, and postinstall logs `skipped: <path> (not a link; …)` — remove it and re-run postinstall to get the link. A postinstall run in a git checkout or worktree (including [an install from a local worktree](#installing-from-a-local-worktree)), outside a `node_modules` directory or with `CI` set leaves `~/.claude/skills/` alone and logs `skipped: <home>/.claude/skills (skill links left alone: <reason>)`. Invoke `/debug-slack-channel-bots` from Claude Code.
1511
1695
 
1512
1696
  **A persona's instance waits at a startup prompt**
1513
1697
  After each launch of a persona's instance (a start, a restart, a resume or a confirmed change's bring-up), the server watches it through agent-director while it starts. It does the same after a launch that timed out, once it finds that launch's instance still starting (see "A persona's instance is still starting" below). When Claude Code's folder-trust or development-channels prompt shows, the server presses Enter to accept it. It types nothing else, and nothing at all when neither prompt shows; the server runs no tmux command for this.
@@ -1597,29 +1781,67 @@ grep -F 'one get after the' ~/.claude/channels/slack/server.log | grep -F '(key=
1597
1781
  `[slack] unavailable-retry: persona=<key> armed in pending-only mode (pending-row) — first retry in 30 s` marks the persona's own launch being watched, and `[slack] unavailable-retry: persona=<key> stopped (pending-only, row <state>) — its row is live out of pending; nothing else is called` its session starting. `[slack] pending-row: "<name>" (key=<key>)'s pending row is not covered (<reason>: <why>) — it goes through the live-row sequence, the conversation not kept (alert context recovery); no approver, nothing typed (b.jg5 SRJ-411)` marks an old instance being replaced, and `… pending row is undecided (…)` one the server can't place yet. The `debug-slack-channel-bots` skill explains every line.
1598
1782
 
1599
1783
  **A persona doesn't receive messages in a channel or DM**
1600
- Check each of these for the persona that should receive the messages:
1784
+ Check each of these for the persona that should receive the messages. In declarative mode:
1601
1785
 
1602
1786
  - Its Slack app is invited to the channel.
1603
1787
  - The channel is in its `channels`, with the `delivery` you want. With `mentions`, only messages that @mention the persona directly and `@here` / `@channel` broadcasts arrive (see [Channel delivery](#channel-delivery)).
1604
1788
  - The edit that added the channel is applied. Until you confirm it, it is still pending (see [Reload](#reload)).
1605
- - For DMs, `dm.enabled` is `true`. An app created from an earlier manifest needs the `im:write` scope and a re-install to start a DM (see [Direct messages](#direct-messages-dmenabled)).
1789
+
1790
+ In fungible mode:
1791
+
1792
+ - Its Slack app is invited to the channel, and the channel is a public or private channel that is not externally shared (see "A persona is silent in a channel it was invited to" below).
1793
+ - Its channel delivery there is the one you want. At `mentions`, only messages that @mention the persona directly and `@here` / `@channel` broadcasts arrive; the persona's agent sets `all` with `set_channel_delivery` when asked, and the loop guard holds another persona's destination channel at `mentions` (see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode)).
1794
+ - The edit that turned fungible mode on is applied (see [Turning fungible mode on or off](#turning-fungible-mode-on-or-off)).
1795
+
1796
+ In both modes, for DMs, `dm.enabled` is `true`. An app created from an earlier manifest needs the `im:write` scope and a re-install to start a DM (see [Direct messages](#direct-messages-dmenabled)).
1606
1797
 
1607
1798
  If the persona is down, see "A persona doesn't come up or doesn't answer" above, or run `/debug-slack-channel-bots`.
1608
1799
 
1800
+ **A persona is silent in a channel it was invited to**
1801
+ An invite is enough only in fungible mode, and only for some conversations. Check the cause:
1802
+
1803
+ - **Declarative mode.** With `allow_invited_channels` absent or `false`, a persona serves only the channels listed in its `channels`, and each message from a channel no applied persona lists logs an `unclaimed-channel` line. List the channel, or turn fungible mode on (see [Channel modes](#channel-modes)).
1804
+ - **An externally shared channel.** Fungible mode never serves a Slack Connect channel; each message there logs an `unclaimed-channel` line with the reason `the channel is externally shared`. Serve it in declarative mode, with the channel listed.
1805
+ - **A group DM.** A DM with more than one person is never served, in either mode.
1806
+ - **An edit that adds a mention.** In fungible mode, an @mention is delivered from its message, so editing an earlier message to add the persona's @mention delivers nothing. Post a new message that mentions it.
1807
+ - **A message posted before the invite.** Slack sends the persona's app no event for a message posted before the app joined the channel. Post it again; the persona can read earlier messages with `fetch_messages` when asked.
1808
+
1809
+ At `mentions`, a message that neither @mentions the persona nor broadcasts is not delivered (see "A persona doesn't receive messages in a channel or DM" above). The first message a persona hears from a channel in fungible mode logs a `persona-invited-channel` line (see [`persona-invited-channel`](skills/debug-slack-channel-bots/SKILL.md#persona-invited-channel) in the debugging skill):
1810
+
1811
+ ```sh
1812
+ grep -E 'persona-invited-channel|unclaimed-channel' ~/.claude/channels/slack/server.log
1813
+ ```
1814
+
1815
+ **A persona receives every message in a channel**
1816
+ In fungible mode, the persona's agent chose `all` for that channel with `set_channel_delivery`; each such choice logs a `persona-channel-delivery-set` line (see [`persona-channel-delivery-set`](skills/debug-slack-channel-bots/SKILL.md#persona-channel-delivery-set) in the debugging skill):
1817
+
1818
+ ```sh
1819
+ grep persona-channel-delivery-set ~/.claude/channels/slack/server.log
1820
+ ```
1821
+
1822
+ To stop it, do one of these:
1823
+
1824
+ - Ask the persona, in that channel or by DM, to switch the channel back to `mentions`.
1825
+ - Remove the persona's app from the channel.
1826
+ - Turn fungible mode off (see [Turning fungible mode on or off](#turning-fungible-mode-on-or-off)).
1827
+
1828
+ In declarative mode, the channel's entry in the persona's `channels` has `delivery: all`: change it to `mentions` and confirm the change.
1829
+
1609
1830
  **File attachment fails after a long wait**
1610
1831
  Each attempt of a Slack request is limited to 30 s, and that includes uploading a file attached with `reply`. An upload that takes longer than 30 s fails on every attempt, so the tool returns an error only after about 30 minutes, once the standard retries are spent. This is not a hang: send smaller files, or split a large attachment into several smaller ones.
1611
1832
 
1612
1833
  **Messages in a channel no persona is configured into are not delivered**
1613
- A channel that is in no persona's `channels` reaches no bot, even when a persona's Slack app is a member. Each such message logs an `unclaimed-channel` line naming the channel and the persona whose app received it in `server.log`:
1834
+ A message a persona's app receives in a channel that no persona serves reaches no bot. Each such message logs an `unclaimed-channel` line naming the channel and the persona whose app received it in `server.log`:
1614
1835
 
1615
1836
  ```sh
1616
1837
  grep unclaimed-channel ~/.claude/channels/slack/server.log
1617
1838
  ```
1618
1839
 
1619
- Add the channel to a persona's `channels` and apply the change (see [Reload](#reload)). Restarting the server alone doesn't apply it.
1840
+ - **In declarative mode,** the channel is in no persona's `channels`, even though a persona's Slack app is a member. Add the channel to a persona's `channels` and apply the change (see [Reload](#reload)); restarting the server alone doesn't apply it. For a public or private channel that is not externally shared, turning fungible mode on is the other fix (see [Channel modes](#channel-modes)); a group DM is never served.
1841
+ - **In fungible mode,** the line says why fungible mode doesn't serve the channel: `the channel is externally shared`, `the event envelope carries no is_ext_shared_channel flag`, `the event envelope's is_ext_shared_channel flag is not a boolean`, `the channel ID is malformed` or `the conversation is not a public or private channel`. For every reason but `the channel ID is malformed`, the channel is served only in declarative mode, with the channel listed. Neither mode serves a malformed channel ID: declarative mode rejects a listed one.
1620
1842
 
1621
1843
  **Permission relay not working**
1622
- Check that the persona's 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` and apply the change (see [Reload](#reload)); like every server-wide setting, it takes effect at the next server start.
1844
+ Check that the persona's 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` and apply the change (see [Reload](#reload)); like every server-wide setting but the switch `allow_invited_channels`, which applies at once, it takes effect at the next server start.
1623
1845
 
1624
1846
  **A permission prompt or notice doesn't arrive**
1625
1847
  When Slack refuses a post to a persona's destination, the server holds the persona's prompts and notices and retries them with backoff. Once Slack accepts posts again, held notices are delivered, and a prompt is posted if its request is still open. It logs one `persona-destination-failed` line in `server.log` naming the persona, its destination and Slack's error, and one `cleared` line when posting works again:
@@ -1629,7 +1851,7 @@ grep persona-destination-failed ~/.claude/channels/slack/server.log
1629
1851
  ```
1630
1852
 
1631
1853
  - `missing_scope` (the line names `im:write`): add the scope and re-install the persona's Slack app; see the `im:write` note under [Direct messages](#direct-messages-dmenabled).
1632
- - `not_in_channel`: invite the persona's app to its `permission_prompts` channel.
1854
+ - `not_in_channel`: invite the persona's app to its destination channel: its `permission_prompts` channel in declarative mode, its `invited.permission_prompts` channel in fungible mode. In fungible mode the line names the setting after the destination, as `destination=<channel> (invited.permission_prompts)`.
1633
1855
 
1634
1856
  Up to 20 notices per persona are kept while it retries; past that the oldest is dropped. The `debug-slack-channel-bots` skill has the full entry for `persona-destination-failed`.
1635
1857
 
@@ -1637,17 +1859,50 @@ Up to 20 notices per persona are kept while it retries; past that the oldest is
1637
1859
  The bot is wedged in `check_permission` on a native Claude Code TUI prompt that never reached Slack (a permission decision AD recorded but could not deliver). The bot stops responding, and after ~90 s the poller posts a one-shot warning naming the persona to the persona's destination (a channel or a DM with its contact). To recover, see the native prompt with `agent-director read-pane --claude-instance-id <id>`, then end the session with `agent-director kill --claude-instance-id <id>` and check the result; on an error, don't delete or respawn: follow the "Operator actions" section of agent-director's README. Once the kill has ended the session, the server brings the persona up again on its own. Do **not** use `send-keys` — agent-director hard-rejects it while the spawn is in this relayed permission state. These commands are for a human only: no bot runs them, including a persona that sees the post. The warning fires once per wedge episode; the detector re-arms if the bot later wedges again. While a confirmed change tears the persona down, the warning is not posted: it goes to `server.log` and `startup-errors.log` as a `persona-teardown-notice` entry.
1638
1860
 
1639
1861
  **Session not restarting after crash**
1640
- Auto-restart backs off exponentially on repeated launch failures — the delay doubles from `session_restart_delay` (default 60s) on each consecutive failure, up to a 15-minute ceiling. After 5 consecutive failures the persona hits a cap: a `SpawnCapReached` notice naming the persona is posted to the persona's destination (a channel or a DM with its contact, per its `permission_prompts`) and automatic restarts stop. A launch that agent-director refuses is not handled by this backoff and cap: it is not counted, and the persona is retried on its own instead, even with `session_restart_delay` set to `0` (see "A persona is retried after agent-director refuses it" below).
1862
+ Auto-restart backs off exponentially on repeated launch failures — the delay doubles from `session_restart_delay` (default 60s) on each consecutive failure, up to a 15-minute ceiling. After 5 consecutive failures the persona hits a cap: a `SpawnCapReached` notice naming the persona is posted to the persona's destination (a channel or a DM with its contact, per its `permission_prompts` in declarative mode or its `invited.permission_prompts` in fungible mode) and automatic restarts stop. A launch that agent-director refuses is not handled by this backoff and cap: it is not counted, and the persona is retried on its own instead, even with `session_restart_delay` set to `0` (see "A persona is retried after agent-director refuses it" below).
1863
+
1864
+ A session that ended cleanly is not treated as a crash. About 3 s after a persona's session disconnects, the server checks its row with agent-director: if the row reads `ended` (Claude Code exited through `/exit`, which is what `agent-director pause` sends), the persona is relaunched then instead of after `session_restart_delay`. Any other reading keeps the full delay: `missing` (a crash or a kill), no row, a session still running or starting, or no answer from agent-director.
1865
+
1866
+ There is no such check while the persona's last launch failed, so failed launches still back off and cap; nothing is logged for that. There is none either within `session_restart_delay` of the persona's last fast relaunch (the loop guard), so a session that exits right after each relaunch is relaunched at most once per `session_restart_delay`.
1867
+
1868
+ The check and the loop guard log these lines in `server.log`:
1869
+
1870
+ ```sh
1871
+ grep b.4vm ~/.claude/channels/slack/server.log
1872
+ ```
1873
+
1874
+ - `[slack] Session for persona=<key> ended cleanly (agent-director reads its row ended) — relaunching now instead of in <N>s (b.4vm)`: the clean exit is relaunched now.
1875
+ - `[slack] Clean-exit check for persona=<key>: <why> — the full <N>s restart delay stands (b.4vm)`: the row didn't read `ended` (`<why>` names what it read, for example `its row does not read ended (dead: the row missing)` after a crash), or the restart was stopped before the read; the restart waits the full delay.
1876
+ - `[slack] No clean-exit check for persona=<key> — its last clean-exit relaunch ran <S>s ago, within session_restart_delay (<base>s); the full <N>s restart delay stands (the clean-exit loop guard: at most one clean-exit relaunch per session_restart_delay) (b.4vm)`: the persona had a fast relaunch less than `session_restart_delay` ago, so the restart waits the full delay. The loop guard runs when the restart is scheduled, before the server reads why the session ended, so this line follows a crash too.
1877
+
1878
+ A resume counts only once its session starts. agent-director's resume checks only that the persona's transcript file exists, and Claude Code exits at once when the transcript holds no conversation it can load: an empty file, one that isn't JSON, or one with only metadata lines. Such a resume is a failed launch once the server reads its row `ended` or `missing`, or finds no row for it. A resume that the server's own kill of the instance ends is not counted.
1879
+
1880
+ A restart's resume that fails this way counts toward the restart limit above. One made when the persona is brought up (at a start or for a confirmed change) does not, unless a restart joins it while it is still starting.
1881
+
1882
+ After 2 such resumes in a row, the server stops resuming that transcript: the persona's next launch brings it up fresh on the same instance, without its conversation. Nothing is deleted: the transcript stays on disk, and the old conversation is kept as an earlier life of the instance.
1883
+
1884
+ Below the restart limit nothing is posted to Slack; `server.log` shows these lines:
1885
+
1886
+ ```sh
1887
+ grep b.4q8 ~/.claude/channels/slack/server.log
1888
+ ```
1889
+
1890
+ - `[slack] <site>: <read> for "<name>" (key=<key>): its resumed launch ended before reporting in (state=<ended|missing|no-row>) — <n> resume(s) in a row ended so; at 2 the ladder makes a reuse spawn in place of a resume (b.4q8)`: a resume ended before its session started (the persona may be named `persona=<key>` instead).
1891
+ - `[slack] Resumed launch failed for persona=<key>: its row ended before reporting in — counted (b.4q8)`: that resume counted as a failed launch.
1892
+ - `[slack] Restart for persona=<key> goes no further <before its kill|before its launch> — the persona reached the restart cap while this restart ran; nothing more is killed or launched for it (b.4q8)`, or `[slack] live-row-sequence: not launching "<name>" (key=<key>) — the persona reached the restart cap while its sequence ran; …`: that failed launch took the persona to the restart limit, so nothing more is launched for it.
1893
+ - `[slack] spawnForPersona: "<name>" (key=<key>): its last <n> resumes in a row ended before reporting in — not resuming its transcript again: a reuse spawn of the same id brings it up fresh, without its conversation; nothing is deleted (b.4q8)`: the persona is brought up fresh.
1894
+
1895
+ The `debug-slack-channel-bots` skill says how to check the transcript (see [A persona's resumed launch ends before it reports in](skills/debug-slack-channel-bots/SKILL.md#a-personas-resumed-launch-ends-before-it-reports-in)).
1641
1896
 
1642
1897
  The server never kills an instance it has just read as ended or missing, or found gone: such an instance needs no kill, and a kill then could end a launch someone started after that read. So a restart that finds the instance ended, missing or gone relaunches the persona with no kill, and so does one whose health check found no proof the old instance is gone (agent-director refused the reconnect, or had already finished the row or had no row for it); `server.log` shows `No kill before the launch for persona=<key>` or `No kill before the relaunch for persona=<key>` before it. The server kills the persona's old instance before a relaunch only when agent-director answers the restart's check that its own install has disappeared, which reads nothing of the instance, and then checks the result. The relaunch follows only a kill that succeeded, or one that found the instance already gone; after any other result nothing is relaunched over the old instance and nothing counts toward the restart limit: the persona is retried on its own, or held for a human (see the *Held:* entries below). `server.log` shows `Session kill for persona=<key> did not succeed (…)` for such a kill. When agent-director reports that it could not end the old worker, the persona's destination also gets a *Kill failed* notice (see "A persona posts a *Kill failed* or *Process outlived kill* notice" below).
1643
1898
 
1644
- A message sent to a persona whose instance can't take it is lost, whether it came from the persona's own channel, a shared channel or a DM: it is not delivered, saved or replayed later, and nothing else is posted where it was sent. Instead, the persona posts one lost-message notice to its destination (its `permission_prompts` channel, or its DM with its contact). That notice is the only post; it lands in the conversation the message came from only when that conversation is the destination.
1899
+ A message sent to a persona whose instance can't take it is lost, whether it came from the persona's own channel, a shared channel or a DM: it is not delivered, saved or replayed later, and nothing else is posted where it was sent. Instead, the persona posts one lost-message notice to its destination (its destination channel, set by `permission_prompts` in declarative mode or `invited.permission_prompts` in fungible mode, or its DM with its contact). That notice is the only post; it lands in the conversation the message came from only when that conversation is the destination.
1645
1900
 
1646
1901
  The notice names the sender (by display name, or user ID; for a bot or webhook post, its name or bot ID) and the recovery state below. It never includes the message text.
1647
1902
 
1648
- - **One notice per lost message.** A persona whose instance can't take messages while its Slack connection is up (held for a human, held because agent-director rejected its launch, after a failed kill, not answering, starting, restarting, while the server replaces its old instance, while it waits on an old instance in its working directory, at the restart limit, or with auto-restart off) posts one notice for each message it loses in a busy `delivery: all` channel. With a `"dm"` destination, each notice is a separate DM to its contact.
1903
+ - **One notice per lost message.** A persona whose instance can't take messages while its Slack connection is up (held for a human, held because agent-director rejected its launch, after a failed kill, not answering, starting, restarting, while the server replaces its old instance, while it waits on an old instance in its working directory, at the restart limit, or with auto-restart off) posts one notice for each message it loses in a busy channel where it receives every message (`delivery: all` in declarative mode, a channel delivery of `all` in fungible mode). With a `"dm"` destination, each notice is a separate DM to its contact.
1649
1904
  - **Held notices are capped.** While the persona can't post to its destination, its notices are held and retried, up to 20 per persona; past that, the oldest is dropped (see "A permission prompt or notice doesn't arrive" above).
1650
- - **Notices reach other personas.** A notice is a Slack post like any other, so a persona with `delivery: all` in the destination channel receives it. When the instances of two personas both can't take messages and each persona receives every message in the other's destination channel, each one's notice is a lost message for the other, so they keep posting notices about each other's notices until one of their instances takes messages again. Give each persona a destination the others don't receive every message in (see [Permission prompts](#permission-prompts)).
1905
+ - **Notices reach other personas.** In declarative mode, a notice is a Slack post like any other, so a persona with `delivery: all` in the destination channel receives it. When the instances of two personas both can't take messages and each persona receives every message in the other's destination channel, each one's notice is a lost message for the other, so they keep posting notices about each other's notices until one of their instances takes messages again. Give each persona a destination the others don't receive every message in (see [Permission prompts](#permission-prompts)). In fungible mode, the loop guard holds every other persona at `mentions` in a persona's destination channel (see [Channel delivery in fungible mode](#channel-delivery-in-fungible-mode)).
1651
1906
 
1652
1907
  The notice reports the first state that applies, in the table's order.
1653
1908
 
@@ -1659,7 +1914,7 @@ The notice reports the first state that applies, in the table's order.
1659
1914
  | `kill failed` | The server could not end a worker of the persona, which may still be running; no restart was started. Either the persona's own old worker could not be ended and the server posted a *Kill failed* notice, or the persona is waiting on an old instance (one still running in its working directory, or its own earlier instance) that a confirmed change's teardown, a server start's clean-up or the server's later kill could not end. For such an old instance nothing is posted: the *Kill failed* text is in `startup-errors.log` (see [Startup errors](#startup-errors)). It applies until agent-director reports that worker's row ended or missing, or no longer has it. A persona that is also held for a human reports `held for a human`. | Follow the *Kill failed* notice, or for an old instance the *Kill failed* text in `startup-errors.log` (see "A persona posts a *Kill failed* or *Process outlived kill* notice" below), then resend once the persona is back. |
1660
1915
  | `not answering` | agent-director or tmux is not answering for the persona, tmux is not available for it, or agent-director refuses its config file, and the server is retrying the persona on its own; no restart was started. It applies only while those retries are running: a persona at its restart limit reports `restart limit reached` instead, whatever is wrong with tmux or agent-director, and one whose retries have stopped reports the next state that applies. | Follow the persona's own notice (see "A persona posts a *Not answering*, *Still not answering* or *Answering again* notice", "A persona posts a *tmux unavailable* or *tmux server changed* notice" and "A persona posts an *agent-director refuses its config file* notice" below), then resend once it's back. |
1661
1916
  | `starting` | The persona's session is starting but has not come up yet, so no restart was started: a launch of it is running, or the server checked with agent-director once, when it found the message lost, and the session was still starting. A check that fails never reports `starting`. | Resend once the persona is up. |
1662
- | `restarting` | A restart of the persona's instance was already under way, or the server is replacing the persona's old instance, still running, on the same instance: with a new conversation, for a relaunch that can't resume it because `resume_enabled` is false or its working directory or config directory changed; or keeping its conversation, for a relaunch after its session was found gone while agent-director still reads it as running. It is also reported while the server is ending an old instance the persona waits on (see "A persona waits on an old instance in its working directory" below), even while the persona's own row reads `pending`. A message lost while the old instance is being replaced or ended starts no restart of its own. | Resend the message (or ask the sender to) once the persona is back. |
1917
+ | `restarting` | A restart of the persona's instance was already under way, or the server is replacing the persona's old instance, still running, on the same instance: with a new conversation, for a relaunch that can't resume it because `resume_enabled` is false or its working directory or config directory changed; or keeping its conversation, for a relaunch after its session was found gone while agent-director still reads it as running. It is also reported while the server is ending an old instance the persona waits on (see "A persona waits on an old instance in its working directory" below), even while the persona's own row reads `pending`. A message lost while the old instance is being replaced or ended starts no restart of its own. A restart after a clean exit, such as `agent-director pause`, relaunches the persona about 3 s after its session disconnected; one after a crash waits `session_restart_delay` first (see above). | Resend the message (or ask the sender to) once the persona is back. |
1663
1918
  | `auto-restart disabled` | `session_restart_delay` is `0`, so no restart was started. A persona the server is already retrying on its own (see "A persona is retried after agent-director refuses it" below) comes back when a retry succeeds; otherwise a server restart recovers it. | If the persona isn't being retried, restart the server; resend once it's back. |
1664
1919
  | `restart limit reached` | The persona is capped; automatic restarts are suspended. It takes precedence over `not answering`: a capped persona reports it even while tmux or agent-director is failing for it, unless a restart is already under way, auto-restart is disabled, or its session is starting. | Restart the server to clear the limit, then resend. |
1665
1920
  | `starting now` | The lost message triggered a fast restart (the backoff delay is clamped down to 5 seconds, never raised). A persona waiting on an old instance in its working directory comes up only once that instance has ended (see "A persona waits on an old instance in its working directory" below). | Resend in a moment, once the persona is back. |
@@ -1732,7 +1987,7 @@ Only that proof, that the bot's session is gone, leads the server to end a sessi
1732
1987
 
1733
1988
  A closely related symptom is a bot that still *looks* connected but silently drops every inbound message — its underlying message stream went away without the connection registering as closed. The same health-check path recovers this on the same two-consecutive-tick cadence, so no inbound message or server restart is needed; with `session_restart_delay` set to `0` the persona's destination gets a *Not receiving messages* notice instead. A message for such a persona that arrives before recovery lands is lost, and the persona's destination gets a lost-message notice as described under "Session not restarting after crash" above.
1734
1989
 
1735
- To verify recovery in the field, tail `server.log` for a stranded persona and confirm a tick-driven recovery lands — look for a `[slack] Scheduling restart for persona=<key> in <N>s (backoff)` line and a `[slack] Session alive but disconnected — reconnecting MCP for persona=<key>` line naming that persona's key. When the bot is mid-turn, with its busy spinner or an API retry message on screen, that line is followed by `[slack] reconnectSession: persona=<key> is working — deferring /mcp reconnect to a later tick (b.9a7/b.rmy)`, and a later tick reconnects it. A bot reported `working` whose screen sits idle and whose transcript ends with a finished reply logs `[slack] reconnectSession: persona=<key> is working; its pane shows an idle screen (no busy indicator, no prompt) and its transcript ends with a completed turn, both unchanged for <N>s of the 60s needed — deferring /mcp reconnect to a later tick, which reads them again (b.f2b)` instead, and then, once both have stayed the same for a minute, `[slack] reconnectSession: persona=<key> reads working, but its pane has shown the same idle screen (no busy indicator, no prompt) and its transcript has ended with a completed turn, both unchanged, for <N>s — treating the row as stale and reconnecting (b.f2b)`. An idle screen whose transcript doesn't end with a finished reply, or can't be read, logs `[slack] reconnectSession: persona=<key> is working and its pane shows an idle screen, but <why> — no idle evidence; deferring /mcp reconnect to a later tick (b.f2b/b.rmy)` and is not reconnected. A bot killed mid-turn logs `[slack] reconnectSession: persona=<key> is working but agent-director's read-pane found no pane of its launch: <error> — not deferring; sweeping and escalating (escalate-dead); the restart path's re-probe decides (read-pane class=<CLASS>; b.d61, b.jg5 SRJ-603)` instead, then `[slack] escalate-dead: persona=<key> verdict=working-tmux-gone — agent-director's read-pane found no pane of the row's launch (GONE) on its working row, triggering internal findMissing reconciliation (the row may stay live for further ticks, each escalate-dead tick sweeping again; ~/startup/find-missing-loop.sh is belt-and-braces)`. The server then checks the row again, and that check decides: once agent-director reads the row dead it logs `[slack] Session reads dead after escalate-dead reconciliation — relaunching in this restart run for persona=<key> (b.d61)` and `[slack] Relaunching session for persona=<key> cwd="<path>"` right after; while agent-director still reads the row live it logs `[slack] Session still reads live after escalate-dead — no relaunch in this restart run for persona=<key>; the row may stay live for further ticks, each escalate-dead tick sweeping again`, relaunches nothing, and the row is swept again at later health checks until it reads dead (see "A persona posts a *Slow recovery* notice" below). A **capped** persona is exempt: the health-check skips it entirely, including reconnects, so a capped persona still requires a server restart (see above).
1990
+ To verify recovery in the field, tail `server.log` for a stranded persona and confirm a tick-driven recovery lands — look for a `[slack] Scheduling restart for persona=<key> in <N>s (backoff)` line; about 3 s later, `[slack] Clean-exit check for persona=<key>: its row does not read ended (live) — the full <N>s restart delay stands (b.4vm)`, since the session still runs and the reconnect waits the full delay (see "Session not restarting after crash" above); and then a `[slack] Session alive but disconnected — reconnecting MCP for persona=<key>` line naming that persona's key. When the bot is mid-turn, with its busy spinner or an API retry message on screen, that line is followed by `[slack] reconnectSession: persona=<key> is working — deferring /mcp reconnect to a later tick (b.9a7/b.rmy)`, and a later tick reconnects it. A bot reported `working` whose screen sits idle and whose transcript ends with a finished reply logs `[slack] reconnectSession: persona=<key> is working; its pane shows an idle screen (no busy indicator, no prompt) and its transcript ends with a completed turn, both unchanged for <N>s of the 60s needed — deferring /mcp reconnect to a later tick, which reads them again (b.f2b)` instead, and then, once both have stayed the same for a minute, `[slack] reconnectSession: persona=<key> reads working, but its pane has shown the same idle screen (no busy indicator, no prompt) and its transcript has ended with a completed turn, both unchanged, for <N>s — treating the row as stale and reconnecting (b.f2b)`. An idle screen whose transcript doesn't end with a finished reply, or can't be read, logs `[slack] reconnectSession: persona=<key> is working and its pane shows an idle screen, but <why> — no idle evidence; deferring /mcp reconnect to a later tick (b.f2b/b.rmy)` and is not reconnected. A bot killed mid-turn logs `[slack] reconnectSession: persona=<key> is working but agent-director's read-pane found no pane of its launch: <error> — not deferring; sweeping and escalating (escalate-dead); the restart path's re-probe decides (read-pane class=<CLASS>; b.d61, b.jg5 SRJ-603)` instead, then `[slack] escalate-dead: persona=<key> verdict=working-tmux-gone — agent-director's read-pane found no pane of the row's launch (GONE) on its working row, triggering internal findMissing reconciliation (the row may stay live for further ticks, each escalate-dead tick sweeping again; ~/startup/find-missing-loop.sh is belt-and-braces)`. The server then checks the row again, and that check decides: once agent-director reads the row dead it logs `[slack] Session reads dead after escalate-dead reconciliation — relaunching in this restart run for persona=<key> (b.d61)` and `[slack] Relaunching session for persona=<key> cwd="<path>"` right after; while agent-director still reads the row live it logs `[slack] Session still reads live after escalate-dead — no relaunch in this restart run for persona=<key>; the row may stay live for further ticks, each escalate-dead tick sweeping again`, relaunches nothing, and the row is swept again at later health checks until it reads dead (see "A persona posts a *Slow recovery* notice" below). A **capped** persona is exempt: the health-check skips it entirely, including reconnects, so a capped persona still requires a server restart (see above).
1736
1991
 
1737
1992
  At a server start, a persona whose instance agent-director reports as `working` is reconnected once its turn ends, and it doesn't hold up the other personas or the health check. `server.log` shows `[slack] startupSessionManager: "<name>" (key=<key>) is waiting for its working row to settle — the start pass goes on without it; its launch stays in flight in the background (b.f2b)`, then, when the wait ends, `[slack] startupSessionManager: background launch for "<name>" (key=<key>) settled: <outcome> (b.f2b)`. `reconnected` means the reconnect was typed into the session; `not-reconnected` means the session was left running without it, and the line just before says what happens next. An idle screen and a transcript ending with a finished reply (or with a turn you interrupted), both unchanged for a minute, end the wait with a reconnect, and a prompt or dialog on screen for a minute raises a *Waiting on a prompt* notice. Otherwise the wait gives up after 10 minutes; if agent-director still reports the persona as `working` then, its destination gets a *Not connected* notice. While agent-director can't report the persona's state, the wait keeps waiting and posts nothing; if it still can't after 10 minutes, the instance is left running for the health check (with `session_restart_delay` set to `0`, a *Not connected* notice reports it), with no `spawn-failed` entry and nothing counted toward the restart limit. If agent-director no longer has a row for the persona, it is relaunched. Removing the persona with a confirmed change ends its wait at once, with nothing typed.
1738
1993
 
@@ -2124,10 +2379,10 @@ Fix the cause first: for `UNAVAILABLE`, confirm agent-director is installed and
2124
2379
  **Bots come back with no memory of the prior conversation after a reboot**
2125
2380
  With `resume_enabled: true`, a bot whose host rebooted (or pod resumed) should return with its conversation history. The agent-director version is not the cause: the server starts only on agent-director Phase 1, since the startup gate refuses any binary below it (`ad-system-install-too-old` or `ad-below-phase1-floor`, see [Startup errors](#startup-errors)). `bun run install-check` passes a binary below Phase 1 only with its note that the server will not start on it (see [Checking your agent-director install](#checking-your-agent-director-install)). A bot that comes back amnesiac on a running server started a new conversation for one of the reasons below, which the log names.
2126
2381
 
2127
- A bot also starts a new conversation, by design, when its session no longer matches the applied configuration. A config edit takes effect only once it is applied (see [Reload](#reload)); a restart or reboot alone runs the last-applied record. When a change to a persona's `working_directory` is applied, the persona is torn down and brought up fresh, once its old instance has ended (see "A persona waits on an old instance in its working directory" above). When a change to a persona's effective `claude_config_dir` (its own or the top-level default) is applied, the bot starts a new conversation the next time it would be resumed (see [When next-launch and server-wide changes take effect](#when-next-launch-and-server-wide-changes-take-effect)); a bot that keeps running keeps its old config directory until then. A bot whose instance no longer matches its working directory or config directory, or whose `resume_enabled` is false, starts a new conversation on the same instance: the old conversation stays in its directory as an earlier life of that instance, and nothing is deleted. A bot that is still running is first ended, with each kill checked, before the new conversation starts; one that is still starting is never typed into: it is ended with a checked kill, and the new conversation starts only once agent-director's grace period has passed since its launch start (see "A persona's instance is still starting" above). A persona retired by a confirmed change (removed and added again with the same key, renamed back, or brought up from its new entry after a `credentials_file` path or `working_directory` change) starts a new conversation on the same instance, restarts included, and its old conversation is never resumed. So does a persona whose instance a server start found while the persona was not in the applied configuration (for example, after `config.json.last-applied` was deleted) and that is added back later: that start recorded its key as retired. After upgrading from an earlier release, each bot also starts fresh once: a bot instance from before the upgrade is never resumed (see [Step 10: Start the new CSCB](#step-10-start-the-new-cscb)). The log names the reason: search `server.log` for `sweeping row`, `pre-persona row`, `replacing the row`, `not resuming; replacing its row` or `key is retired`.
2382
+ A bot also starts a new conversation, by design, when its session no longer matches the applied configuration. A config edit takes effect only once it is applied (see [Reload](#reload)); a restart or reboot alone runs the last-applied record. When a change to a persona's `working_directory` is applied, the persona is torn down and brought up fresh, once its old instance has ended (see "A persona waits on an old instance in its working directory" above). When a change to a persona's effective `claude_config_dir` (its own or the top-level default) is applied, the bot starts a new conversation the next time it would be resumed (see [When next-launch and server-wide changes take effect](#when-next-launch-and-server-wide-changes-take-effect)); a bot that keeps running keeps its old config directory until then. A bot whose instance no longer matches its working directory or config directory, or whose `resume_enabled` is false, starts a new conversation on the same instance: the old conversation stays in its directory as an earlier life of that instance, and nothing is deleted. A bot that is still running is first ended, with each kill checked, before the new conversation starts; one that is still starting is never typed into: it is ended with a checked kill, and the new conversation starts only once agent-director's grace period has passed since its launch start (see "A persona's instance is still starting" above). A persona retired by a confirmed change (removed and added again with the same key, renamed back, or brought up from its new entry after a `credentials_file` path or `working_directory` change) starts a new conversation on the same instance, restarts included, and its old conversation is never resumed. So does a persona whose instance a server start found while the persona was not in the applied configuration (for example, after `config.json.last-applied` was deleted) and that is added back later: that start recorded its key as retired. After upgrading from an earlier release, each bot also starts fresh once: a bot instance from before the upgrade is never resumed (see [Step 10: Start the new CSCB](#step-10-start-the-new-cscb)). A bot whose last 2 resumes ended before its session started also starts fresh: its transcript holds no conversation Claude Code can load (see "Session not restarting after crash" above). The log names the reason: search `server.log` for `sweeping row`, `pre-persona row`, `replacing the row`, `not resuming; replacing its row`, `key is retired` or `not resuming its transcript again`.
2128
2383
 
2129
2384
  **Session crashes on resume with "sandbox required but unavailable"**
2130
- 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`, apply the change and restart the server (server-wide settings take effect at the next start; see [Reload](#reload)) 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:
2385
+ 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`, apply the change and restart the server (`resume_enabled`, like every server-wide setting but `allow_invited_channels`, takes effect at the next start; see [Reload](#reload)) 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:
2131
2386
 
2132
2387
  ```json
2133
2388
  {
@@ -2144,7 +2399,7 @@ The server daemon writes runtime output to `~/.claude/channels/slack/server.log`
2144
2399
 
2145
2400
  `stop --stop-bots` and `clean_restart` append their bot-teardown lines to `server.log` while the server is stopped (see [What the command prints when a bot can't be stopped](#what-the-command-prints-when-a-bot-cant-be-stopped)); those lines take the same timestamped form and are rotated the same way.
2146
2401
 
2147
- Note this covers only `server.log` / `clean_restart.log`. `startup-errors.log` and `permission-trail.jsonl` are separate append-only files with their own retention (see below).
2402
+ Note this covers only `server.log` / `clean_restart.log`. `startup-errors.log` and `permission-trail.jsonl` are separate append-only files with their own retention (see below). The cron log is never rotated; `cron_log_max_bytes` caps it instead (see [The cron log](#the-cron-log)).
2148
2403
 
2149
2404
  ---
2150
2405
 
@@ -2222,9 +2477,9 @@ The following classes are **non-fatal warnings** about conversation-memory loss.
2222
2477
  - `jsonl-non-persistent` — a session-transcript storage root (`<claude_config_dir>/projects`) is on a `tmpfs`/`ramfs` mount, so nothing there survives a reboot and session resume is structurally impossible on this host. A warning is also posted to the destination of each affected persona — those whose transcript storage root is the flagged mount, not every persona. Move the config dir to a persistent filesystem.
2223
2478
  - `jsonl-persistence-check-warning` — the safeguard could not determine the filesystem type of a transcript storage root (unreadable/unparseable `/proc/self/mountinfo`, or an unresolvable path), so persistence is unverified. No Slack post is made. Investigate the mount before relying on resume.
2224
2479
  - `jsonl-transcript-stale-path` — a persona's saved transcript exists on disk at the resolved fallback path, but agent-director's recorded `jsonl_path` points elsewhere (missing/empty). On the next restart the resume path would treat it as missing and wipe the persona's memory. A warning is posted to the persona's destination; an operator should reconcile the path before restarting.
2225
- - `jsonl-transcript-lost` — a persona's transcript is gone from disk (neither the recorded nor the fallback path exists), yet the message archive shows messages in the persona's `delivery: all` channels since the bot spawned. Only those channels are counted. Conversation history has been lost and resume will start the bot fresh. A warning is posted to the persona's destination. Requires `message_archive_db` to be configured for the archive evidence. When the archive shows nothing, the safeguard logs a quiet line only: no transcript is expected for a persona idle since spawn, and for a persona with a `delivery: mentions` channel or DMs on a zero count proves nothing. A row the server will replace rather than resume (its working directory or config directory changed) is not checked.
2226
- - `jsonl-transcript-lost-on-resume` — resume actually threw `ErrJsonlMissing` for a persona, so the bot is brought up fresh on the same instance and its agent-director row is kept (its history archived to the earlier life), and the message archive shows messages in the persona's `delivery: all` channels since it spawned — so conversation history was lost, not merely at risk. Only those channels are counted. Once the bot is up, a warning is also posted to the persona's destination ("my conversation memory has been lost and I was brought up fresh by a reuse spawn of the same instance, and its row is kept (its history archived to the earlier life)"); when bringing it up fails, the entry stays and no warning is posted. This is the resume path's own after-the-fact report (distinct from the pre-resume `jsonl-transcript-lost` warning above); the log line names every transcript path tried and whether each came from agent-director or was computed locally. A missing transcript on a persona that was *idle since spawn* — the archive was consulted and shows zero messages since spawn, and every one of the persona's channels is `delivery: all` with DMs off — is expected (the transcript is created lazily on first message) and produces a quiet log line only, no error class and no Slack post. Requires `message_archive_db` for the archive evidence; when the archive cannot be consulted the case is instead reported as `jsonl-diagnosis-inconclusive` below.
2227
- - `jsonl-diagnosis-inconclusive` — resume threw `ErrJsonlMissing`, so the bot is brought up fresh on the same instance and its agent-director row is kept, but the diagnosis could not determine whether conversation history was lost: the agent-director row was already gone, `started_at` was unparseable, the message archive could not be read, `message_archive_db` is not configured at all, or the archive shows zero messages but the persona has a `delivery: mentions` channel or DMs on, so a zero count cannot show it was idle (the archive cannot attribute messages from `mentions` channels or DMs to a persona). Because "inconclusive" correlates with the same storage problems that cause real loss, this is surfaced (not silently downgraded to a benign never-created): the line records *why* the diagnosis failed, and once the bot is up, a warning is posted to the persona's destination worded as uncertainty ("on restart I was brought up fresh by a reuse spawn of the same instance, and its row is kept (its history archived to the earlier life); I could not determine whether my prior conversation history was preserved") rather than as a confirmed loss; when bringing it up fails, the entry stays and no warning is posted. When the reason is an unconfigured archive, the message notes that diagnosis is impossible without `message_archive_db` and suggests enabling it. These personas are counted separately in the startup summary as `fresh-after-inconclusive-amnesia` (distinct from the `fresh-after-amnesia` count). When agent-director can't answer that row fetch at all (it is unavailable, or any other error), nothing is diagnosed: no entry is recorded, no warning is posted, the bot is not brought up, and the launch is retried later like any other refused launch. When that row fetch finds conflicting labels noted on the persona's own row, nothing is diagnosed or brought up either: the persona is held (see "A persona posts a *Held: tmux session conflict* notice" under Troubleshooting). So is it when that row fetch answers that the row's recorded tmux session name can't be used (see "A persona posts a *Held: unusable tmux session name* notice" under Troubleshooting), or finds the persona's own row pending with no launch start (see "A persona posts a *Held: launch start not recorded* notice" under Troubleshooting).
2480
+ - `jsonl-transcript-lost` — in declarative mode, a persona's transcript is gone from disk (neither the recorded nor the fallback path exists), yet the message archive shows messages in the persona's `delivery: all` channels since the bot spawned. Only those channels are counted. In fungible mode the archive counts no channel, so this class is never reported: the safeguard logs only its quiet line. Conversation history has been lost and resume will start the bot fresh. A warning is posted to the persona's destination. Requires `message_archive_db` to be configured for the archive evidence. When the archive shows nothing, the safeguard logs a quiet line only: no transcript is expected for a persona idle since spawn, and for a persona with a `delivery: mentions` channel or DMs on a zero count proves nothing. A row the server will replace rather than resume (its working directory or config directory changed) is not checked.
2481
+ - `jsonl-transcript-lost-on-resume` — in declarative mode, resume actually threw `ErrJsonlMissing` for a persona, so the bot is brought up fresh on the same instance and its agent-director row is kept (its history archived to the earlier life), and the message archive shows messages in the persona's `delivery: all` channels since it spawned — so conversation history was lost, not merely at risk. Only those channels are counted. In fungible mode the resume path reports `jsonl-diagnosis-inconclusive` instead. Once the bot is up, a warning is also posted to the persona's destination ("my conversation memory has been lost and I was brought up fresh by a reuse spawn of the same instance, and its row is kept (its history archived to the earlier life)"); when bringing it up fails, the entry stays and no warning is posted. This is the resume path's own after-the-fact report (distinct from the pre-resume `jsonl-transcript-lost` warning above); the log line names every transcript path tried and whether each came from agent-director or was computed locally. A missing transcript on a persona that was *idle since spawn* — the archive was consulted and shows zero messages since spawn, and every one of the persona's channels is `delivery: all` with DMs off — is expected (the transcript is created lazily on first message) and produces a quiet log line only, no error class and no Slack post. Requires `message_archive_db` for the archive evidence; when the archive cannot be consulted the case is instead reported as `jsonl-diagnosis-inconclusive` below.
2482
+ - `jsonl-diagnosis-inconclusive` — resume threw `ErrJsonlMissing`, so the bot is brought up fresh on the same instance and its agent-director row is kept, but the diagnosis could not determine whether conversation history was lost: the agent-director row was already gone, `started_at` was unparseable, the message archive could not be read, `message_archive_db` is not configured at all, or the archive shows zero messages but the persona has a `delivery: mentions` channel or DMs on, so a zero count cannot show it was idle (the archive cannot attribute messages from `mentions` channels or DMs to a persona), or the server is in fungible mode (`allow_invited_channels` is true), where a persona serves every channel its app is a member of, so the archive counts no channel and a zero count proves nothing. Because "inconclusive" correlates with the same storage problems that cause real loss, this is surfaced (not silently downgraded to a benign never-created): the line records *why* the diagnosis failed, and once the bot is up, a warning is posted to the persona's destination worded as uncertainty ("on restart I was brought up fresh by a reuse spawn of the same instance, and its row is kept (its history archived to the earlier life); I could not determine whether my prior conversation history was preserved") rather than as a confirmed loss; when bringing it up fails, the entry stays and no warning is posted. When the reason is an unconfigured archive, the message notes that diagnosis is impossible without `message_archive_db` and suggests enabling it. These personas are counted separately in the startup summary as `fresh-after-inconclusive-amnesia` (distinct from the `fresh-after-amnesia` count). When agent-director can't answer that row fetch at all (it is unavailable, or any other error), nothing is diagnosed: no entry is recorded, no warning is posted, the bot is not brought up, and the launch is retried later like any other refused launch. When that row fetch finds conflicting labels noted on the persona's own row, nothing is diagnosed or brought up either: the persona is held (see "A persona posts a *Held: tmux session conflict* notice" under Troubleshooting). So is it when that row fetch answers that the row's recorded tmux session name can't be used (see "A persona posts a *Held: unusable tmux session name* notice" under Troubleshooting), or finds the persona's own row pending with no launch start (see "A persona posts a *Held: launch start not recorded* notice" under Troubleshooting).
2228
2483
 
2229
2484
  ---
2230
2485
 
@@ -2563,11 +2818,20 @@ This major version accepts only the persona configuration format. These are the
2563
2818
  2. **Pick persona names whose keys don't start with one another.** The configuration check rejects such a pair. If your bots were named `horde`, `horde_admin`, …, don't name a persona `horde` beside `horde_admin`: give the shorter name a suffix, such as `horde_main` (see [Persona name and key](#persona-name-and-key)).
2564
2819
  3. **Set the reply settings in `config.json`.** Nothing else carries an acknowledgement reaction over: to keep one, set `ack_reaction` as a top-level setting. If you had changed how replies are split, set `reply_chunk_limit` and `reply_chunk_mode` there too. See [Server-wide settings](#server-wide-settings).
2565
2820
  4. **Move the tokens into credentials files.** Tokens come only from each persona's credentials file. Create one [credentials file](#credentials-files) per persona, then remove any token environment variables you exported for the previous version.
2566
- 5. **Give each persona its own Slack app.** Your existing app can serve one persona; create another app for each additional persona. Re-install the existing app from the current `slack-app-manifest.yml` so it gains the `im:write` scope; the `debug-slack-channel-bots` skill has the steps under "A persona can't open a DM".
2567
- 6. **Decide who can reach each persona.** Who can reach a persona is decided only by its `channels`, each channel's `delivery` and its `dm.enabled` switch (see [Channel delivery](#channel-delivery) and [Direct messages](#direct-messages-dmenabled)).
2821
+ 5. **Give each persona its own Slack app.** Your existing app can serve one persona; create another app for each additional persona. Add the `im:write`, `channels:read` and `groups:read` bot scopes to the existing app and re-install it; the `debug-slack-channel-bots` skill has the steps under "A persona can't open a DM" and "The message archive shows channel IDs".
2822
+ 6. **Decide who can reach each persona.** In declarative mode, the mode an upgrade starts in, who can reach a persona is decided only by its `channels`, each channel's `delivery` and its `dm.enabled` switch (see [Channel delivery](#channel-delivery) and [Direct messages](#direct-messages-dmenabled)). Fungible mode changes that (see [Who can reach a persona in fungible mode](#who-can-reach-a-persona-in-fungible-mode)).
2568
2823
  7. **Rewrite crontable lines to name personas.** A crontable target that names a channel matches no persona, and the line is logged `unknown-persona` each time it fires. Rewrite each target as a persona's name or key (see [Scheduled Prompts](#scheduled-prompts-cscb_cron)).
2569
2824
  8. **Update `/interject` callers to send `persona`.** A request without `persona` is refused with 400, and a successful response holds only `ok` and `persona`. Change every script that calls `/interject`, including host crontab `curl` lines, to name a persona by name or key (see [Interject](#interject)).
2570
2825
 
2826
+ ### Downgrading to an earlier release
2827
+
2828
+ An earlier release's loader rejects `allow_invited_channels` and `invited` as unknown keys, and a restarted server runs the last-applied record, so the record must not hold either key when the older build starts. A host whose `config.json` has never held either key downgrades as from any release: install the older build and restart the server on it. Otherwise, follow these steps in order, with the server running:
2829
+
2830
+ 1. **Give every persona a valid declarative section.** Turning fungible mode off is checked by the declarative rules, so each persona needs a `permission_prompts`, and `channels` unless its `dm.enabled` is `true`, that pass them (see [Load-time rules](#load-time-rules)).
2831
+ 2. **Remove `allow_invited_channels` and every persona's `invited` from `config.json`.**
2832
+ 3. **Confirm the pending change, and wait for its `reload-applied` or `reload-noop` line** (see [Confirming a change](#confirming-a-change)). Either one means the last-applied record holds the configuration without the two keys. A host that was already in declarative mode confirms as `reload-noop`.
2833
+ 4. **Install the older build and restart the server on it.** For example, `bun install -g claude-slack-channel-bots@<version>`, then `claude-slack-channel-bots stop` and `claude-slack-channel-bots start`. The older build ignores `channel-delivery.json`, so the file can stay where it is.
2834
+
2571
2835
  ---
2572
2836
 
2573
2837
  ## Upgrading from pre-Epic-2 (v0.5.x → v0.6.x)