claude-slack-channel-bots 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ See the sections below for manual configuration details if you prefer not to use
38
38
 
39
39
  - [Bun](https://bun.sh) `>= 1.0.21` (agent-director minimum)
40
40
  - [Claude Code](https://claude.ai/code) installed and authenticated
41
- - [`agent-director`](https://github.com/gabemahoney/agent-director) **runtime dependency** — pulled in transitively when you install this package. Hard required at server boot: CSCB refuses to start if the library is missing, the host platform is unsupported, Bun is too old, or the installed version is below `MIN_AD_VERSION` (`^0.4.3`). agent-director itself requires [tmux](https://github.com/tmux/tmux) on the operator's PATH; CSCB no longer probes for it directly.
41
+ - [`agent-director`](https://github.com/gabemahoney/agent-director) **runtime dependency** — pulled in transitively when you install this package. Hard required at server boot: CSCB refuses to start if the library is missing, the host platform is unsupported, Bun is too old, or the installed version is below `MIN_AD_VERSION` (`^0.5.5`). agent-director itself requires [tmux](https://github.com/tmux/tmux) on the operator's PATH; CSCB no longer probes for it directly.
42
42
  - Slack workspace admin access (to create and configure the Slack app)
43
43
  - **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — used by JSONL path resolution helpers retained for downstream callers.
44
44
 
@@ -54,7 +54,7 @@ See the sections below for manual configuration details if you prefer not to use
54
54
 
55
55
  If the host is unsupported, the SR-5.1 startup gate exits non-zero at server boot with a typed error from agent-director (`ErrPlatformPackageMissing` or `ErrUnsupportedPlatform`) and writes the failure to `~/.claude/channels/slack/startup-errors.log` and stderr. See [Startup errors](#startup-errors) below.
56
56
 
57
- > **Note on agent-director versions.** v0.4.1 is a zombie release (the published tarball is missing `dist/` and cannot be imported). v0.4.2 lacks the `MakeTemplateParams.overwrite` field CSCB needs for the boot-time template refresh. CSCB pins `^0.4.3` to get past both.
57
+ > **Note on agent-director versions.** v0.4.1 is a zombie release (the published tarball is missing `dist/` and cannot be imported). v0.4.2 lacks the `MakeTemplateParams.overwrite` field CSCB needs for the boot-time template refresh. v0.5.4 and earlier lack `allow_pending` on `readPane`/`sendKeys`, causing `ErrSpawnNotInteractive` during dev-channels dialog approval on freshly-spawned bots. CSCB pins `^0.5.5` to get past all three.
58
58
 
59
59
  ---
60
60
 
@@ -271,6 +271,16 @@ Behavior by case:
271
271
 
272
272
  The PID file is stored at `STATE_DIR/server.pid` (default: `~/.claude/channels/slack/server.pid`). It is written on startup and removed on clean shutdown. A conflict check at startup prevents running two servers against the same state directory.
273
273
 
274
+ ### Installing from a local worktree
275
+
276
+ To install the version of CSCB sitting in your working copy (so the globally-linked `claude-slack-channel-bots` binary runs your local sources), use the helper script rather than `bun install -g .`:
277
+
278
+ ```sh
279
+ ./scripts/install-local.sh
280
+ ```
281
+
282
+ `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.
283
+
274
284
  ### Direct invocation for development
275
285
 
276
286
  Skip the CLI and run the server directly with Bun for development or debugging:
@@ -436,6 +446,56 @@ Classes you may see:
436
446
 
437
447
  ---
438
448
 
449
+ ## Release process
450
+
451
+ CSCB releases are cut with the `/publish` skill from a clean checkout of `main` on a dev box that has `npm login` against the publishing account. The skill bumps the version, packs and smoke-tests the release tarball, commits and tags the release, pushes to GitHub, publishes to npm, polls the registry until the new version is visible, reinstalls the just-published version on the dev box, and prints a final summary.
452
+
453
+ ### Invocation
454
+
455
+ ```
456
+ /publish <patch|minor|major>
457
+ ```
458
+
459
+ The bump kind is **required** — there is no default. The skill exits with a usage line if the argument is missing or not one of `patch`, `minor`, `major`.
460
+
461
+ ### Preflight gates
462
+
463
+ Before any side-effecting step runs, `/publish` enforces six fail-fast gates. Any failure aborts before the version is bumped, the tarball is packed, or anything is committed:
464
+
465
+ 1. **Clean working tree on `main` in sync with origin/main.** No uncommitted changes; HEAD branch is `main`; `main` is exactly equal to `origin/main` after `git fetch origin`.
466
+ 2. **Tests exist and pass.** At least one `*.test.ts` file under `tests/` and `bun test` exits zero.
467
+ 3. **Typecheck passes.** `bun run typecheck` exits zero.
468
+ 4. **npm authenticated.** `npm whoami` exits zero (run `npm login` first if not).
469
+ 5. **Next version not already published.** `npm view claude-slack-channel-bots@<next-version> version` must report nothing.
470
+ 6. **`/ci` integration suite passes.** The full Docker-based integration test suite is run via the `/ci` skill and must report PASS. **`/ci` is mandatory and has no opt-out flag** — release without an unbroken integration run is not possible through this skill.
471
+
472
+ ### What happens during a release
473
+
474
+ After all gates pass, the skill, in this order:
475
+
476
+ 1. Bumps `package.json` and `bun.lock` to `<next-version>` (no commit, no tag yet).
477
+ 2. Packs the release tarball with `bun pm pack` and verifies its internal version matches.
478
+ 3. Scratch-installs the tarball into a temp `BUN_INSTALL` and runs the bin smoke check (non-zero exit + `Usage:` in stderr). Any failure here rolls back the working tree and aborts — no commit, no push, no publish.
479
+ 4. Creates the `Release v<version>` commit and the annotated `v<version>` tag locally.
480
+ 5. Pushes the release commit to `origin/main`.
481
+ 6. Publishes the smoke-tested tarball with `npm publish <tarball-path>` (the smoke-tested artifact bytes — not a repack from CWD).
482
+ 7. Pushes the `v<version>` tag to `origin`, bringing GitHub and npm into agreement.
483
+ 8. Polls the npm registry every 5 seconds for up to 60 seconds until the new version is visible.
484
+ 9. Sanitizes the bun-1.3.13 empty-string-dependency-key poison from the global `package.json` (see [Installing from a local worktree](#installing-from-a-local-worktree)), removes any pre-existing global install, then runs `bun install -g claude-slack-channel-bots@<version>` — the exact command an end user would run — and verifies the installed bin resolves under `~/.bun/install/global/` at the published version.
485
+ 10. Prints a success summary identifying the published version, npm URL, GitHub release tag URL, resolved local install path, and the next-operator-action command.
486
+
487
+ ### After the skill exits
488
+
489
+ The dev box now has the freshly-published version installed globally as a real copy, but the running CSCB daemon is still on the prior version. Swap the daemon over:
490
+
491
+ ```sh
492
+ claude-slack-channel-bots clean_restart
493
+ ```
494
+
495
+ This gracefully exits the managed Claude Code sessions, stops and restarts the server on the new binary, and brings each session back up. See [`clean_restart`](#claude-slack-channel-bots-clean_restart) above for full behavior.
496
+
497
+ ---
498
+
439
499
  ## Migration
440
500
 
441
501
  For operators upgrading from a pre-`agent-director` install:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Multi-session Slack-to-Claude bridge — run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "@modelcontextprotocol/sdk": "^1.0.0",
31
31
  "@slack/socket-mode": "^2.0.0",
32
32
  "@slack/web-api": "^7.0.0",
33
- "agent-director": "^0.5.1"
33
+ "agent-director": "^0.5.5"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/bun": "^1.0.0",
package/src/cli.ts CHANGED
@@ -112,10 +112,15 @@ export function createCli(deps: CliDeps): CliHandlers {
112
112
  const { spawn } = await import('child_process')
113
113
  const logPath = join(stateDir, 'server.log')
114
114
  const logFd = openSync(logPath, 'a')
115
+ // b.1m9: propagate --reconcile-instance-ids to the daemon child via env.
116
+ const childEnv: NodeJS.ProcessEnv = { ...process.env, _CLI_DAEMON_CHILD: '1' }
117
+ if (process.argv.includes('--reconcile-instance-ids')) {
118
+ childEnv['CSCB_RECONCILE_INSTANCE_IDS'] = '1'
119
+ }
115
120
  const child = spawn(process.execPath, [import.meta.filename, 'start'], {
116
121
  detached: true,
117
122
  stdio: ['ignore', logFd, logFd],
118
- env: { ...process.env, _CLI_DAEMON_CHILD: '1' },
123
+ env: childEnv,
119
124
  })
120
125
  child.unref()
121
126
  console.error(`[slack] Server starting in background (PID ${child.pid})`)
@@ -294,14 +299,34 @@ if (import.meta.main) {
294
299
  const subcommand = process.argv[2]
295
300
 
296
301
  if (subcommand !== 'start' && subcommand !== 'stop' && subcommand !== 'clean_restart') {
297
- console.error('Usage: cli.ts <start|stop|clean_restart>')
302
+ console.error('Usage: cli.ts <start|stop|clean_restart> [flags]')
298
303
  console.error('')
299
304
  console.error(' start Validate prerequisites and start the server in the background')
300
305
  console.error(' stop Send SIGTERM to a running server')
301
306
  console.error(' clean_restart Exit all managed sessions, then stop and start the server')
307
+ console.error('')
308
+ console.error('Flags (b.1m9):')
309
+ console.error(' --reconcile-instance-ids Auto-delete stale pre-rename cscb_<id> AD rows on startup')
302
310
  process.exit(1)
303
311
  }
304
312
 
313
+ // Resolve a channel's actual claude_instance_id by querying agent-director's
314
+ // label index. Survives the b.1m9 naming change (cscb_<name>_<id>) without
315
+ // requiring the CLI to know the route's normalizedName. Returns null when
316
+ // no cscb row exists for the channel; falls back to bare-ID on a list-level
317
+ // error so legacy behavior is preserved.
318
+ async function resolveCscbInstanceId(channelId: string): Promise<string | null> {
319
+ try {
320
+ const r = await getClient().list({ label: ['service=cscb', `channel=${channelId}`] })
321
+ if (r.spawns.length === 0) return null
322
+ // Prefer the new-naming row if both old and new exist mid-migration.
323
+ const newStyle = r.spawns.find((s) => s.claude_instance_id !== `cscb_${channelId}`)
324
+ return (newStyle ?? r.spawns[0]).claude_instance_id
325
+ } catch {
326
+ return null
327
+ }
328
+ }
329
+
305
330
  const realDeps: CliDeps = {
306
331
  spawnSync: (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }),
307
332
  env: process.env,
@@ -315,8 +340,13 @@ if (import.meta.main) {
315
340
  exit: (code) => process.exit(code),
316
341
  loadConfig: () => configLoadConfig(),
317
342
  directorStatus: async (channelId) => {
343
+ // Resolve the actual claude_instance_id by label (cscb_<name>_<id> after b.1m9,
344
+ // or cscb_<id> on pre-rename installs). Falls back to bare-ID lookup if
345
+ // listing isn't possible, preserving compatibility with single-row stubs.
346
+ const id = await resolveCscbInstanceId(channelId)
347
+ if (id === null) return null
318
348
  try {
319
- const r = await getClient().status({ claude_instance_id: instanceIdFor(channelId) })
349
+ const r = await getClient().status({ claude_instance_id: id })
320
350
  return { state: r.state }
321
351
  } catch (err) {
322
352
  if (err instanceof ErrSpawnNotFound) return null
@@ -324,11 +354,13 @@ if (import.meta.main) {
324
354
  }
325
355
  },
326
356
  directorPause: async (channelId) => {
327
- await getClient().pause({ claude_instance_id: instanceIdFor(channelId) })
357
+ const id = (await resolveCscbInstanceId(channelId)) ?? instanceIdFor(channelId)
358
+ await getClient().pause({ claude_instance_id: id })
328
359
  },
329
360
  directorKill: async (channelId) => {
330
361
  try {
331
- await getClient().kill({ claude_instance_id: instanceIdFor(channelId) })
362
+ const id = (await resolveCscbInstanceId(channelId)) ?? instanceIdFor(channelId)
363
+ await getClient().kill({ claude_instance_id: id })
332
364
  } catch (err) {
333
365
  if (err instanceof ErrSpawnNotFound) return
334
366
  throw err
package/src/config.ts CHANGED
@@ -76,6 +76,30 @@ export interface RouteEntry {
76
76
  * if neither is set).
77
77
  */
78
78
  claude_config_dir?: string
79
+ /**
80
+ * Runtime-resolved Slack channel name (e.g. "horde-agent-director").
81
+ * Populated by the startup `conversations.info` resolver and refreshed
82
+ * opportunistically by event handlers. Never read from config.json — these
83
+ * fields exist on the in-memory record only and the validator rejects them
84
+ * if present in the JSON payload.
85
+ */
86
+ name?: string
87
+ /**
88
+ * Runtime-resolved normalized channel name suitable for use in tmux
89
+ * session names and agent-director `claude_instance_id`. Produced by
90
+ * `normalizeChannelName(name)` whenever `name` is set.
91
+ */
92
+ normalizedName?: string
93
+ }
94
+
95
+ /**
96
+ * Normalize a Slack channel name into a token safe for tmux session names
97
+ * and agent-director `claude_instance_id` values: lowercased, all non
98
+ * `[a-z0-9]` runs collapsed to a single `_`, and any leading or trailing
99
+ * underscores stripped. Returns `''` when the input contains no alnum chars.
100
+ */
101
+ export function normalizeChannelName(name: string): string {
102
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
79
103
  }
80
104
 
81
105
  /** Raw shape of config.json as parsed from disk. All optional fields may be absent. */
package/src/server.ts CHANGED
@@ -44,8 +44,11 @@ import {
44
44
  flushSpawnFailureQueue,
45
45
  instanceIdFor,
46
46
  launchSession,
47
+ reconcileInstanceIds,
47
48
  reconcileOrphans,
48
49
  reconnectMcp,
50
+ refreshRouteNameFromEvent,
51
+ resolveChannelNames,
49
52
  startupSessionManager,
50
53
  } from './session-manager.ts'
51
54
  import { cleanSession, getCozempicAvailable } from './cozempic.ts'
@@ -682,6 +685,7 @@ socket.on('message', async ({ event, ack }) => {
682
685
  await ack()
683
686
  if (!event) return
684
687
  archiveInboundMessage(event)
688
+ if (routingConfig) refreshRouteNameFromEvent(routingConfig, event)
685
689
  try {
686
690
  await handleMessage(event)
687
691
  } catch (err) {
@@ -694,6 +698,7 @@ socket.on('app_mention', async ({ event, ack }) => {
694
698
  await ack()
695
699
  if (!event) return
696
700
  archiveInboundMessage(event)
701
+ if (routingConfig) refreshRouteNameFromEvent(routingConfig, event)
697
702
  try {
698
703
  await handleMessage(event)
699
704
  } catch (err) {
@@ -701,6 +706,15 @@ socket.on('app_mention', async ({ event, ack }) => {
701
706
  }
702
707
  })
703
708
 
709
+ // Capture channel renames as a separate event — Slack delivers a channel_name
710
+ // field here that lets us refresh the cached name without waiting for the next
711
+ // message on the channel.
712
+ socket.on('channel_rename', async ({ event, ack }) => {
713
+ await ack()
714
+ if (!event) return
715
+ if (routingConfig) refreshRouteNameFromEvent(routingConfig, event)
716
+ })
717
+
704
718
  socket.on('interactive', async (evt) => {
705
719
  const { ack } = evt as { ack: () => Promise<void> }
706
720
  const p = ((evt as any).body ?? (evt as any).payload ?? evt) as Record<string, unknown>
@@ -1085,7 +1099,7 @@ export async function main(): Promise<void> {
1085
1099
  // back to "dead" defensively — health-check will retry.
1086
1100
  const isSessionAliveAdapter = async (channelId: string): Promise<boolean> => {
1087
1101
  if (!routingConfig?.routes[channelId]) return false
1088
- const claude_instance_id = instanceIdFor(channelId)
1102
+ const claude_instance_id = instanceIdFor(channelId, routingConfig.routes[channelId]?.normalizedName)
1089
1103
  try {
1090
1104
  const r = await getClient().status({ claude_instance_id })
1091
1105
  return AGENT_DIRECTOR_LIVE_STATES.has(r.state)
@@ -1106,11 +1120,12 @@ export async function main(): Promise<void> {
1106
1120
  return session?.connected === true
1107
1121
  },
1108
1122
  reconnectSession: async (channelId) => {
1109
- await reconnectMcp(channelId, isDryRun() ? undefined : web)
1123
+ await reconnectMcp(channelId, isDryRun() ? undefined : web, routingConfig ?? undefined)
1110
1124
  },
1111
1125
  killSession: async (channelId) => {
1112
1126
  try {
1113
- await getClient().kill({ claude_instance_id: instanceIdFor(channelId) })
1127
+ const normalizedName = routingConfig?.routes[channelId]?.normalizedName
1128
+ await getClient().kill({ claude_instance_id: instanceIdFor(channelId, normalizedName) })
1114
1129
  } catch (err) {
1115
1130
  if (err instanceof ErrSpawnNotFound) return
1116
1131
  console.error(`[slack] killSession (restart adapter): error for channel=${channelId}:`, err)
@@ -1137,6 +1152,30 @@ export async function main(): Promise<void> {
1137
1152
  }
1138
1153
  }
1139
1154
 
1155
+ // b.1m9: resolve channel names from Slack so per-route spawns get the
1156
+ // glanceable `cscb_<name>_<id>` / `slack_bot_<name>_<id>` naming. Failures
1157
+ // are non-fatal: nameless routes fall back to the legacy bare-ID form.
1158
+ if (routingConfig) {
1159
+ try {
1160
+ await resolveChannelNames(routingConfig, isDryRun() ? undefined : web)
1161
+ } catch (err) {
1162
+ console.error('[slack] Warning: channel-name resolution failed:', err)
1163
+ }
1164
+ }
1165
+
1166
+ // b.1m9: warn about (or, with --reconcile-instance-ids, delete) stale
1167
+ // pre-rename rows whose claude_instance_id doesn't match the new naming.
1168
+ // Must run AFTER name resolution so the expected ids are right.
1169
+ if (routingConfig) {
1170
+ const autoDelete = process.argv.includes('--reconcile-instance-ids') ||
1171
+ process.env['CSCB_RECONCILE_INSTANCE_IDS'] === '1'
1172
+ try {
1173
+ await reconcileInstanceIds(routingConfig, autoDelete)
1174
+ } catch (err) {
1175
+ console.error('[slack] Warning: instance-id reconcile failed:', err)
1176
+ }
1177
+ }
1178
+
1140
1179
  // Per-route reconcile via library: spawnForRoute dispatches fresh-spawn or
1141
1180
  // collision-handling per SR-1.4. Failures are surfaced to the affected Slack
1142
1181
  // channel; the server stays up.
@@ -36,7 +36,7 @@ import type { ListRow, SpawnParams } from 'agent-director'
36
36
  import type { WebClient } from '@slack/web-api'
37
37
 
38
38
  import { checkCozempicAvailable } from './cozempic.ts'
39
- import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
39
+ import { type RoutingConfig, MCP_SERVER_NAME, normalizeChannelName } from './config.ts'
40
40
  import { getClient } from './agent-director-client.ts'
41
41
  import { recordStartupError } from './startup-errors.ts'
42
42
  import { isDryRun } from './tokens.ts'
@@ -61,16 +61,48 @@ export const AGENT_DIRECTOR_LIVE_STATES: ReadonlySet<string> = new Set([
61
61
  /** The CSCB-shipped template name (mirrors agent-director-client). */
62
62
  const TEMPLATE_NAME = 'slack-channel-bot'
63
63
 
64
- /** Build the deterministic claude_instance_id for a channelId. */
65
- export function instanceIdFor(channelId: string): string {
64
+ /**
65
+ * Build the deterministic claude_instance_id for a channelId.
66
+ *
67
+ * When `normalizedName` is a non-empty string, the id is composed as
68
+ * `cscb_${normalizedName}_${channelId}` for operator glanceability in
69
+ * `agent-director list`. When omitted or empty, falls back to the bare
70
+ * `cscb_${channelId}` form so callers without a resolved name still produce
71
+ * a stable id. The channelId always suffixes — it is the canonical key
72
+ * and survives channel renames.
73
+ */
74
+ export function instanceIdFor(channelId: string, normalizedName?: string): string {
75
+ if (normalizedName && normalizedName.length > 0) {
76
+ return `cscb_${normalizedName}_${channelId}`
77
+ }
66
78
  return `cscb_${channelId}`
67
79
  }
68
80
 
69
- /** Build the canonical tmux session name for a channelId. */
70
- export function tmuxSessionNameFor(channelId: string): string {
81
+ /**
82
+ * Build the canonical tmux session name for a channelId.
83
+ *
84
+ * Mirrors `instanceIdFor` composition: with a name, `slack_bot_${name}_${id}`;
85
+ * without, `slack_bot_${id}`. The id suffix keeps sessions unique across
86
+ * channel renames or collisions between channels that normalize identically.
87
+ */
88
+ export function tmuxSessionNameFor(channelId: string, normalizedName?: string): string {
89
+ if (normalizedName && normalizedName.length > 0) {
90
+ return `slack_bot_${normalizedName}_${channelId}`
91
+ }
71
92
  return `slack_bot_${channelId}`
72
93
  }
73
94
 
95
+ /**
96
+ * Look up the cached normalized channel name on a route. Returns undefined
97
+ * when the route is missing or the name has not been resolved yet.
98
+ */
99
+ export function getNormalizedNameForChannel(
100
+ channelId: string,
101
+ routingConfig: RoutingConfig,
102
+ ): string | undefined {
103
+ return routingConfig.routes[channelId]?.normalizedName
104
+ }
105
+
74
106
  // ---------------------------------------------------------------------------
75
107
  // Spawn-failure queue — Slack-error surface (SR-1.1 channel-post path)
76
108
  // ---------------------------------------------------------------------------
@@ -144,8 +176,12 @@ export function flushSpawnFailureQueue(web: WebClient): void {
144
176
  * Send `/mcp reconnect <MCP_SERVER_NAME>` to the spawn's pane. Library's
145
177
  * sendKeys appends Enter automatically per its contract.
146
178
  */
147
- export async function reconnectMcp(channelId: string, web?: WebClient): Promise<boolean> {
148
- const claude_instance_id = instanceIdFor(channelId)
179
+ export async function reconnectMcp(
180
+ channelId: string,
181
+ web?: WebClient,
182
+ routingConfig?: RoutingConfig,
183
+ ): Promise<boolean> {
184
+ const claude_instance_id = instanceIdFor(channelId, routingConfig?.routes[channelId]?.normalizedName)
149
185
  console.error(`[slack] reconnecting MCP server "${MCP_SERVER_NAME}": channel=${channelId}`)
150
186
  try {
151
187
  await getClient().sendKeys({
@@ -161,6 +197,113 @@ export async function reconnectMcp(channelId: string, web?: WebClient): Promise<
161
197
  }
162
198
  }
163
199
 
200
+ // ---------------------------------------------------------------------------
201
+ // approveDevChannelsDialog — auto-approve the dev-channels warning on spawn
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Verified against Claude Code 2.1.120 (2026-05-27). If this stops matching,
206
+ * the dev-channels dialog has drifted — see b.yy6. Match the option label
207
+ * (semantic, stable) rather than the header (cosmetic, drifts).
208
+ */
209
+ export const DEV_CHANNELS_DIALOG_NEEDLE = 'I am using this for local development'
210
+
211
+ /** Default poll interval while watching for the dev-channels dialog. */
212
+ export const DIALOG_POLL_INTERVAL_MS = 500
213
+
214
+ /** Default deadline for both the appearance poll and the still-visible poll. */
215
+ export const DIALOG_POLL_TIMEOUT_MS = 30_000
216
+
217
+ /** Number of consecutive "needle absent" reads required to confirm approval. */
218
+ export const DIALOG_GONE_CONFIRMS_REQUIRED = 2
219
+
220
+ let _dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
221
+ let _dialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
222
+
223
+ /** Test-only seam: override the dev-channels poll interval. */
224
+ export function _setDialogPollIntervalMs(ms: number): void {
225
+ _dialogPollIntervalMs = ms
226
+ }
227
+
228
+ /** Test-only seam: restore the default poll interval. */
229
+ export function _resetDialogPollIntervalMs(): void {
230
+ _dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
231
+ }
232
+
233
+ /** Test-only seam: override the dev-channels poll timeout. */
234
+ export function _setDialogPollTimeoutMs(ms: number): void {
235
+ _dialogPollTimeoutMs = ms
236
+ }
237
+
238
+ /** Test-only seam: restore the default poll timeout. */
239
+ export function _resetDialogPollTimeoutMs(): void {
240
+ _dialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
241
+ }
242
+
243
+ /**
244
+ * Poll the freshly-spawned bot's tmux pane until the dev-channels approval
245
+ * dialog appears, send Enter to accept the pre-selected
246
+ * "I am using this for local development" option, then confirm the dialog
247
+ * has cleared. All errors caught locally — never throws to the caller.
248
+ *
249
+ * readPane/sendKeys use `allow_pending: true` because the bot is still in
250
+ * `pending` AD state when this runs (b.98w — formerly caused ErrSpawnNotInteractive).
251
+ *
252
+ * Two distinct failure modes are recorded via `recordStartupError` so a
253
+ * future Claude Code release that changes the dialog cannot silently break
254
+ * fresh-spawn approval:
255
+ * - `dev-channels-approve-no-dialog` — needle never observed
256
+ * - `dev-channels-approve-still-visible` — needle persists after Enter
257
+ */
258
+ export async function approveDevChannelsDialog(
259
+ channelId: string,
260
+ web: WebClient | undefined,
261
+ isStartup: boolean,
262
+ ): Promise<void> {
263
+ void web
264
+ const claude_instance_id = instanceIdFor(channelId)
265
+ const client = getClient()
266
+ const deadline = Date.now() + _dialogPollTimeoutMs
267
+
268
+ let approved = false
269
+ while (Date.now() < deadline) {
270
+ try {
271
+ const { pane } = await client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true })
272
+ if (pane.includes(DEV_CHANNELS_DIALOG_NEEDLE)) {
273
+ await client.sendKeys({ claude_instance_id, text: '', allow_pending: true })
274
+ approved = true
275
+ break
276
+ }
277
+ } catch (err) {
278
+ console.error(`[slack] approveDevChannelsDialog: readPane error channel=${channelId}: ${String(err)}`)
279
+ }
280
+ await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
281
+ }
282
+
283
+ if (!approved) {
284
+ const msg = `dev-channels dialog never appeared for channel=${channelId} within ${_dialogPollTimeoutMs}ms (dialog text may have drifted — see b.yy6)`
285
+ console.error(`[slack] approveDevChannelsDialog: ${msg}`)
286
+ if (isStartup) recordStartupError('dev-channels-approve-no-dialog', msg)
287
+ return
288
+ }
289
+
290
+ let misses = 0
291
+ while (Date.now() < deadline && misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
292
+ await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
293
+ try {
294
+ const { pane } = await client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true })
295
+ misses = pane.includes(DEV_CHANNELS_DIALOG_NEEDLE) ? 0 : misses + 1
296
+ } catch {
297
+ /* tolerate transient readPane failure */
298
+ }
299
+ }
300
+ if (misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
301
+ const msg = `dev-channels dialog still visible after Enter for channel=${channelId} (sendKeys may not have reached pane)`
302
+ console.error(`[slack] approveDevChannelsDialog: ${msg}`)
303
+ if (isStartup) recordStartupError('dev-channels-approve-still-visible', msg)
304
+ }
305
+ }
306
+
164
307
  // ---------------------------------------------------------------------------
165
308
  // waitForWaitingAndReconnect — used when a colliding spawn is in `working`
166
309
  // ---------------------------------------------------------------------------
@@ -191,7 +334,7 @@ export async function waitForWaitingAndReconnect(
191
334
  routingConfig: RoutingConfig,
192
335
  web?: WebClient,
193
336
  ): Promise<boolean> {
194
- const claude_instance_id = instanceIdFor(channelId)
337
+ const claude_instance_id = instanceIdFor(channelId, routingConfig.routes[channelId]?.normalizedName)
195
338
  const pollIntervalMs = routingConfig.agent_director_poll_interval_ms
196
339
  const deadline = Date.now() + _waitForWaitingTimeoutMs
197
340
 
@@ -212,7 +355,7 @@ export async function waitForWaitingAndReconnect(
212
355
  }
213
356
 
214
357
  if (state === 'waiting') {
215
- return reconnectMcp(channelId, web)
358
+ return reconnectMcp(channelId, web, routingConfig)
216
359
  }
217
360
 
218
361
  if (state === 'working') {
@@ -247,12 +390,13 @@ function buildSpawnParams(
247
390
  ): SpawnParams {
248
391
  const effectiveConfigDir =
249
392
  routingConfig.routes[channelId]?.claude_config_dir ?? routingConfig.claude_config_dir
393
+ const normalizedName = routingConfig.routes[channelId]?.normalizedName
250
394
  const params: SpawnParams = {
251
395
  template: TEMPLATE_NAME,
252
396
  cwd: route.cwd,
253
- claude_instance_id: instanceIdFor(channelId),
397
+ claude_instance_id: instanceIdFor(channelId, normalizedName),
254
398
  relay_mode: 'on',
255
- tmux_session_name: tmuxSessionNameFor(channelId),
399
+ tmux_session_name: tmuxSessionNameFor(channelId, normalizedName),
256
400
  label: ['service=cscb', `channel=${channelId}`],
257
401
  }
258
402
  if (effectiveConfigDir) {
@@ -262,18 +406,23 @@ function buildSpawnParams(
262
406
  }
263
407
 
264
408
  /** Best-effort kill — never throws. */
265
- async function tryKill(channelId: string): Promise<void> {
409
+ async function tryKill(channelId: string, normalizedName: string | undefined): Promise<void> {
266
410
  try {
267
- await getClient().kill({ claude_instance_id: instanceIdFor(channelId) })
411
+ await getClient().kill({ claude_instance_id: instanceIdFor(channelId, normalizedName) })
268
412
  } catch {
269
413
  /* ignore */
270
414
  }
271
415
  }
272
416
 
273
417
  /** Delete the spawn row; surface failures. Returns whether the delete succeeded. */
274
- async function tryDelete(channelId: string, web: WebClient | undefined, isStartup: boolean): Promise<boolean> {
418
+ async function tryDelete(
419
+ channelId: string,
420
+ normalizedName: string | undefined,
421
+ web: WebClient | undefined,
422
+ isStartup: boolean,
423
+ ): Promise<boolean> {
275
424
  try {
276
- await getClient().delete({ claude_instance_id: [instanceIdFor(channelId)] })
425
+ await getClient().delete({ claude_instance_id: [instanceIdFor(channelId, normalizedName)] })
277
426
  return true
278
427
  } catch (err) {
279
428
  const e = err instanceof AgentDirectorError ? err : new AgentDirectorError('delete', 'UnknownError', String(err))
@@ -311,12 +460,14 @@ export async function spawnForRoute(
311
460
  }
312
461
 
313
462
  const params = buildSpawnParams(channelId, route, routingConfig)
463
+ const normalizedName = routingConfig.routes[channelId]?.normalizedName
314
464
  const client = getClient()
315
465
 
316
466
  // Attempt fresh spawn ---
317
467
  try {
318
468
  const r = await client.spawn(params)
319
469
  console.error(`[slack] spawnForRoute: spawned channel=${channelId} instanceId=${r.claude_instance_id}`)
470
+ await approveDevChannelsDialog(channelId, web, isStartup)
320
471
  return { channelId, action: 'spawned' }
321
472
  } catch (err) {
322
473
  if (!(err instanceof ErrInstanceIdCollision)) {
@@ -333,7 +484,7 @@ export async function spawnForRoute(
333
484
  // Collision-handling: get-then-act ---
334
485
  let state: string
335
486
  try {
336
- const r = await client.get({ claude_instance_id: instanceIdFor(channelId) })
487
+ const r = await client.get({ claude_instance_id: instanceIdFor(channelId, normalizedName) })
337
488
  state = r.state
338
489
  } catch (err) {
339
490
  if (err instanceof ErrSpawnNotFound) {
@@ -342,6 +493,7 @@ export async function spawnForRoute(
342
493
  try {
343
494
  const r = await client.spawn(params)
344
495
  console.error(`[slack] spawnForRoute: retry-spawn succeeded for channel=${channelId} instanceId=${r.claude_instance_id}`)
496
+ await approveDevChannelsDialog(channelId, web, isStartup)
345
497
  return { channelId, action: 'spawned' }
346
498
  } catch (err2) {
347
499
  const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
@@ -362,11 +514,12 @@ export async function spawnForRoute(
362
514
  if (state === 'ended' || state === 'missing') {
363
515
  if (routingConfig.resume_enabled === false) {
364
516
  console.error(`[slack] spawnForRoute: resume_enabled=false — kill+delete+fresh for channel=${channelId}`)
365
- await tryKill(channelId)
366
- if (!(await tryDelete(channelId, web, isStartup))) return { channelId, action: 'failed' }
517
+ await tryKill(channelId, normalizedName)
518
+ if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
367
519
  try {
368
520
  await client.spawn(params)
369
521
  console.error(`[slack] spawnForRoute: fresh-spawned (after kill+delete) for channel=${channelId}`)
522
+ await approveDevChannelsDialog(channelId, web, isStartup)
370
523
  return { channelId, action: 'spawned' }
371
524
  } catch (err) {
372
525
  const e = err instanceof AgentDirectorError ? err : new AgentDirectorError('spawn', 'UnknownError', String(err))
@@ -380,16 +533,17 @@ export async function spawnForRoute(
380
533
  // resume_enabled: attempt resume
381
534
  console.error(`[slack] spawnForRoute: attempting resume for channel=${channelId}`)
382
535
  try {
383
- await client.resume({ claude_instance_id: instanceIdFor(channelId) })
536
+ await client.resume({ claude_instance_id: instanceIdFor(channelId, normalizedName) })
384
537
  console.error(`[slack] spawnForRoute: resumed channel=${channelId}`)
385
538
  return { channelId, action: 'resumed' }
386
539
  } catch (err) {
387
540
  if (err instanceof ErrNoSessionId || err instanceof ErrJsonlMissing) {
388
541
  console.error(`[slack] spawnForRoute: ${err.errName} on resume for channel=${channelId} — delete+fresh`)
389
- if (!(await tryDelete(channelId, web, isStartup))) return { channelId, action: 'failed' }
542
+ if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
390
543
  try {
391
544
  await client.spawn(params)
392
545
  console.error(`[slack] spawnForRoute: fresh-spawned (after delete) for channel=${channelId}`)
546
+ await approveDevChannelsDialog(channelId, web, isStartup)
393
547
  return { channelId, action: 'spawned' }
394
548
  } catch (err2) {
395
549
  const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
@@ -402,10 +556,11 @@ export async function spawnForRoute(
402
556
  if (err instanceof ErrSpawnNotResumable) {
403
557
  // Row is non-terminal but resume rejected — defensive: kill + delete + spawn
404
558
  console.error(`[slack] spawnForRoute: ErrSpawnNotResumable for channel=${channelId} — kill+delete+fresh`)
405
- await tryKill(channelId)
406
- if (!(await tryDelete(channelId, web, isStartup))) return { channelId, action: 'failed' }
559
+ await tryKill(channelId, normalizedName)
560
+ if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
407
561
  try {
408
562
  await client.spawn(params)
563
+ await approveDevChannelsDialog(channelId, web, isStartup)
409
564
  return { channelId, action: 'spawned' }
410
565
  } catch (err2) {
411
566
  const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
@@ -422,7 +577,7 @@ export async function spawnForRoute(
422
577
  }
423
578
 
424
579
  if (state === 'waiting') {
425
- await reconnectMcp(channelId, web)
580
+ await reconnectMcp(channelId, web, routingConfig)
426
581
  return { channelId, action: 'reconnected' }
427
582
  }
428
583
 
@@ -525,6 +680,220 @@ export async function reconcileOrphans(
525
680
  return { found, killed, failed }
526
681
  }
527
682
 
683
+ // ---------------------------------------------------------------------------
684
+ // Channel-name resolution (b.1m9)
685
+ // ---------------------------------------------------------------------------
686
+
687
+ export interface ChannelNameResolveResult {
688
+ channelId: string
689
+ name?: string
690
+ normalizedName?: string
691
+ /** When set, conversations.info failed; route stays nameless and falls back to bare-ID naming. */
692
+ error?: string
693
+ }
694
+
695
+ /** Minimal WebClient surface this module needs — just conversations.info. */
696
+ export type ChannelInfoClient = {
697
+ conversations: {
698
+ info: (args: { channel: string }) => Promise<{ channel?: { name?: string } }>
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Resolve and cache Slack channel names for every route at startup.
704
+ *
705
+ * For each `routingConfig.routes[channelId]`, call `conversations.info` once
706
+ * and stash the result on `route.name` + `route.normalizedName`. Sessions
707
+ * spawned during startup then carry the new `slack_bot_<name>_<id>` /
708
+ * `cscb_<name>_<id>` naming for operator glanceability.
709
+ *
710
+ * Failure is non-fatal: any per-route rejection (network, missing scope,
711
+ * unknown channel, no `channel.name` field) logs a single line and leaves
712
+ * the route nameless. `instanceIdFor` / `tmuxSessionNameFor` then fall back
713
+ * to bare-ID naming, preserving pre-b.1m9 behavior for that one route.
714
+ *
715
+ * Mutates `routingConfig.routes` in place. Returns per-route diagnostics for
716
+ * the operator and for tests.
717
+ */
718
+ export async function resolveChannelNames(
719
+ routingConfig: RoutingConfig,
720
+ web: ChannelInfoClient | undefined,
721
+ ): Promise<ChannelNameResolveResult[]> {
722
+ const results: ChannelNameResolveResult[] = []
723
+ if (!web) {
724
+ // Dry-run or otherwise no WebClient — leave every route nameless.
725
+ console.error('[slack] resolveChannelNames: no WebClient available — skipping')
726
+ return results
727
+ }
728
+ for (const [channelId, route] of Object.entries(routingConfig.routes)) {
729
+ try {
730
+ const resp = await web.conversations.info({ channel: channelId })
731
+ const name = resp.channel?.name
732
+ if (!name) {
733
+ const r: ChannelNameResolveResult = { channelId, error: 'no name on conversations.info response' }
734
+ console.error(`[slack] resolveChannelNames: channel=${channelId} → (no name) — falling back to bare-ID`)
735
+ results.push(r)
736
+ continue
737
+ }
738
+ const normalizedName = normalizeChannelName(name)
739
+ route.name = name
740
+ route.normalizedName = normalizedName.length > 0 ? normalizedName : undefined
741
+ console.error(
742
+ `[slack] resolveChannelNames: channel=${channelId} → "${name}" (normalized="${route.normalizedName ?? ''}")`,
743
+ )
744
+ results.push({ channelId, name, normalizedName: route.normalizedName })
745
+ } catch (err) {
746
+ const msg = err instanceof Error ? err.message : String(err)
747
+ console.error(`[slack] resolveChannelNames: channel=${channelId} → error: ${msg} — falling back to bare-ID`)
748
+ results.push({ channelId, error: msg })
749
+ }
750
+ }
751
+ return results
752
+ }
753
+
754
+ /**
755
+ * Opportunistically refresh a route's cached channel name from an incoming
756
+ * Slack event. Slack only includes `channel.name` on some event types
757
+ * (channel_rename, channel_archive, etc.); message events typically don't
758
+ * carry it. When it IS present, refreshing here covers channel renames
759
+ * without a CSCB restart.
760
+ *
761
+ * No-ops when the event has no channel name, no matching route, or the
762
+ * cached name is already up to date.
763
+ */
764
+ export function refreshRouteNameFromEvent(
765
+ routingConfig: RoutingConfig,
766
+ event: unknown,
767
+ ): void {
768
+ if (!event || typeof event !== 'object') return
769
+ const ev = event as Record<string, unknown>
770
+
771
+ let channelId: string | undefined
772
+ let channelName: string | undefined
773
+
774
+ // Form A: { channel: 'C…', channel_name: 'foo' } — used by channel_rename
775
+ if (typeof ev['channel'] === 'string') {
776
+ channelId = ev['channel'] as string
777
+ if (typeof ev['channel_name'] === 'string') channelName = ev['channel_name'] as string
778
+ }
779
+ // Form B: { channel: { id: 'C…', name: 'foo' } } — used by channel_archive, etc.
780
+ if (channelName === undefined && ev['channel'] && typeof ev['channel'] === 'object') {
781
+ const ch = ev['channel'] as Record<string, unknown>
782
+ if (typeof ch['id'] === 'string') channelId = ch['id'] as string
783
+ if (typeof ch['name'] === 'string') channelName = ch['name'] as string
784
+ }
785
+
786
+ if (!channelId || !channelName) return
787
+ const route = routingConfig.routes[channelId]
788
+ if (!route) return
789
+ if (route.name === channelName) return
790
+
791
+ const normalizedName = normalizeChannelName(channelName)
792
+ route.name = channelName
793
+ route.normalizedName = normalizedName.length > 0 ? normalizedName : undefined
794
+ console.error(
795
+ `[slack] refreshRouteNameFromEvent: channel=${channelId} → "${channelName}" (normalized="${route.normalizedName ?? ''}")`,
796
+ )
797
+ }
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // Instance-id migration (b.1m9)
801
+ // ---------------------------------------------------------------------------
802
+
803
+ export interface InstanceIdMigrationResult {
804
+ /** Rows whose claude_instance_id doesn't match the route's expected new-naming id. */
805
+ orphans: Array<{ channelId: string; oldInstanceId: string; expectedInstanceId: string }>
806
+ /** When auto-delete is on: count of rows we successfully removed. */
807
+ deleted: number
808
+ /** When auto-delete is on: count of rows whose delete failed. */
809
+ failed: number
810
+ }
811
+
812
+ /**
813
+ * Detect agent-director rows whose `claude_instance_id` predates the b.1m9
814
+ * naming change (`cscb_<id>`) for channels we now spawn as
815
+ * `cscb_<name>_<id>`. The bare-ID rows are orphans the next time the server
816
+ * starts; the new-naming spawn won't collide with them, so they linger.
817
+ *
818
+ * Default behavior: warn only, one line per orphan listing the exact
819
+ * `agent-director delete --claude-instance-id …` command the operator can
820
+ * paste. With `autoDelete=true`, this function calls `client.delete` for
821
+ * each orphan instead.
822
+ *
823
+ * Note: a row whose channel label is not in `routingConfig.routes` at all
824
+ * is handled by `reconcileOrphans` (SR-1.6), not here.
825
+ */
826
+ export async function reconcileInstanceIds(
827
+ routingConfig: RoutingConfig,
828
+ autoDelete: boolean,
829
+ ): Promise<InstanceIdMigrationResult> {
830
+ const empty: InstanceIdMigrationResult = { orphans: [], deleted: 0, failed: 0 }
831
+ if (isDryRun()) {
832
+ console.error('[slack] dry-run: skipping instance-id reconcile')
833
+ return empty
834
+ }
835
+
836
+ const client = getClient()
837
+ let rows: ListRow[]
838
+ try {
839
+ const r = await client.list({ label: ['service=cscb'] })
840
+ rows = r.spawns
841
+ } catch (err) {
842
+ const e = err instanceof AgentDirectorError ? err : new AgentDirectorError('list', 'UnknownError', String(err))
843
+ console.error(`[slack] reconcileInstanceIds: list failed — skipping: ${e.errName}`)
844
+ return empty
845
+ }
846
+
847
+ const orphans: InstanceIdMigrationResult['orphans'] = []
848
+ for (const row of rows) {
849
+ const channelId = row.labels['channel']
850
+ if (!channelId) continue
851
+ const route = routingConfig.routes[channelId]
852
+ if (!route) continue // covered by reconcileOrphans
853
+ const expected = instanceIdFor(channelId, route.normalizedName)
854
+ if (row.claude_instance_id === expected) continue
855
+ orphans.push({ channelId, oldInstanceId: row.claude_instance_id, expectedInstanceId: expected })
856
+ }
857
+
858
+ if (orphans.length === 0) {
859
+ return empty
860
+ }
861
+
862
+ if (!autoDelete) {
863
+ console.error(
864
+ `[slack] reconcileInstanceIds: found ${orphans.length} row(s) with stale claude_instance_id ` +
865
+ `(pre-b.1m9 naming). The new spawn(s) will not collide; the old row(s) will linger. ` +
866
+ `Pass --reconcile-instance-ids to auto-delete, or run the commands below:`,
867
+ )
868
+ for (const o of orphans) {
869
+ console.error(
870
+ `[slack] reconcileInstanceIds: channel=${o.channelId} stale=${o.oldInstanceId} ` +
871
+ `expected=${o.expectedInstanceId} — agent-director delete --claude-instance-id ${o.oldInstanceId}`,
872
+ )
873
+ }
874
+ return { orphans, deleted: 0, failed: 0 }
875
+ }
876
+
877
+ let deleted = 0
878
+ let failed = 0
879
+ for (const o of orphans) {
880
+ console.error(
881
+ `[slack] reconcileInstanceIds: deleting stale row channel=${o.channelId} instanceId=${o.oldInstanceId}`,
882
+ )
883
+ try {
884
+ await client.delete({ claude_instance_id: [o.oldInstanceId] })
885
+ deleted++
886
+ } catch (err) {
887
+ const e = err instanceof AgentDirectorError ? err : new AgentDirectorError('delete', 'UnknownError', String(err))
888
+ console.error(
889
+ `[slack] reconcileInstanceIds: delete failed for channel=${o.channelId} instanceId=${o.oldInstanceId}: ${e.errName}`,
890
+ )
891
+ failed++
892
+ }
893
+ }
894
+ return { orphans, deleted, failed }
895
+ }
896
+
528
897
  // ---------------------------------------------------------------------------
529
898
  // startupSessionManager — iterate routes and dispatch per-channel
530
899
  // ---------------------------------------------------------------------------