claude-slack-channel-bots 0.6.0 → 0.6.2
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 +62 -2
- package/package.json +2 -2
- package/src/cli.ts +37 -5
- package/src/config.ts +24 -0
- package/src/server.ts +42 -3
- package/src/session-manager.ts +392 -23
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.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.6.2",
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/session-manager.ts
CHANGED
|
@@ -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
|
-
/**
|
|
65
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
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(
|
|
148
|
-
|
|
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(
|
|
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
|
// ---------------------------------------------------------------------------
|