claude-slack-channel-bots 0.8.0 → 0.8.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/session-manager.ts +349 -153
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.8.0",
3
+ "version": "0.8.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": {
@@ -31,6 +31,7 @@ import {
31
31
  ErrNoSessionId,
32
32
  ErrSpawnNotFound,
33
33
  ErrSpawnNotResumable,
34
+ ErrTmuxSessionCreate,
34
35
  } from 'agent-director'
35
36
  import type { ListRow, SpawnParams } from 'agent-director'
36
37
  import type { WebClient } from '@slack/web-api'
@@ -206,7 +207,7 @@ export async function reconnectMcp(
206
207
  }
207
208
 
208
209
  // ---------------------------------------------------------------------------
209
- // approveDevChannelsDialog — auto-approve the dev-channels warning on spawn
210
+ // approvePreSessionDialogs — auto-approve pre-SessionStart dialogs on spawn
210
211
  // ---------------------------------------------------------------------------
211
212
 
212
213
  /**
@@ -216,19 +217,19 @@ export async function reconnectMcp(
216
217
  */
217
218
  export const DEV_CHANNELS_DIALOG_NEEDLE = 'I am using this for local development'
218
219
 
219
- /** Default poll interval while watching for the dev-channels dialog. */
220
- export const DIALOG_POLL_INTERVAL_MS = 500
221
-
222
- /** Default deadline for both the appearance poll and the still-visible poll. */
223
- export const DIALOG_POLL_TIMEOUT_MS = 30_000
220
+ /**
221
+ * Verified against Claude Code 2.1.120 (2026-06-02). If this stops matching,
222
+ * the folder-trust dialog has drifted — see b.k54 / b.uhv. Match the option
223
+ * label (semantic, stable) rather than the header (cosmetic, drifts).
224
+ */
225
+ export const TRUST_DIALOG_NEEDLE = 'Yes, I trust this folder'
224
226
 
225
- /** Number of consecutive "needle absent" reads required to confirm approval. */
226
- export const DIALOG_GONE_CONFIRMS_REQUIRED = 2
227
+ /** Default poll interval while watching for pre-session dialogs. */
228
+ export const DIALOG_POLL_INTERVAL_MS = 500
227
229
 
228
230
  let _dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
229
- let _dialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
230
231
 
231
- /** Test-only seam: override the dev-channels poll interval. */
232
+ /** Test-only seam: override the dialog poll interval. */
232
233
  export function _setDialogPollIntervalMs(ms: number): void {
233
234
  _dialogPollIntervalMs = ms
234
235
  }
@@ -238,182 +239,256 @@ export function _resetDialogPollIntervalMs(): void {
238
239
  _dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
239
240
  }
240
241
 
241
- /** Test-only seam: override the dev-channels poll timeout. */
242
- export function _setDialogPollTimeoutMs(ms: number): void {
243
- _dialogPollTimeoutMs = ms
242
+ /** Hard cap: how long to wait for a fresh spawn to leave `pending` (reach a
243
+ * live SessionStart state) while auto-dismissing pre-session dialogs. */
244
+ export const DIALOG_READY_TIMEOUT_MS = 5 * 60_000
245
+
246
+ let _dialogReadyTimeoutMs = DIALOG_READY_TIMEOUT_MS
247
+
248
+ /** Test-only seam: override the ready cap. */
249
+ export function _setDialogReadyTimeoutMs(ms: number): void {
250
+ _dialogReadyTimeoutMs = ms
244
251
  }
245
252
 
246
- /** Test-only seam: restore the default poll timeout. */
247
- export function _resetDialogPollTimeoutMs(): void {
248
- _dialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
253
+ /** Test-only seam: restore the default ready cap. */
254
+ export function _resetDialogReadyTimeoutMs(): void {
255
+ _dialogReadyTimeoutMs = DIALOG_READY_TIMEOUT_MS
249
256
  }
250
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // Raw-tmux dialog fallback (b.vub) — bypass agent-director when the row is in
260
+ // a non-interactive state (missing/ended) but the pane still shows the dialog.
261
+ // ---------------------------------------------------------------------------
262
+
251
263
  /**
252
- * Poll the freshly-spawned bot's tmux pane until the dev-channels approval
253
- * dialog appears, send Enter to accept the pre-selected
254
- * "I am using this for local development" option, then confirm the dialog
255
- * has cleared. All errors caught locally — never throws to the caller.
264
+ * b.vub — the critical mechanism the ticket's manual mitigation used.
256
265
  *
257
- * readPane/sendKeys use `allow_pending: true` because the bot is still in
258
- * `pending` AD state when this runs (b.98w — formerly caused ErrSpawnNotInteractive).
266
+ * On the RESUME lap, `client.resume` re-creates the tmux session but the AD row
267
+ * stays `missing`/`ended` until SessionStart re-fires — and SessionStart can't
268
+ * fire because the dev-channels dialog blocks it. Crucially, agent-director's
269
+ * `read-pane` / `send-keys` REFUSE to touch a `missing`/`ended` row
270
+ * (`ErrSpawnNotInteractive`, even with allow_pending), so the approver cannot
271
+ * clear the dialog through AD. A raw `tmux capture-pane` + `tmux send-keys …
272
+ * Enter` DOES clear it (verified against real AD/tmux; this is exactly the
273
+ * `tmux send-keys -t <session> Enter` the operator ran by hand in the ticket),
274
+ * after which AD flips to `waiting`.
259
275
  *
260
- * Two distinct failure modes are recorded via `recordStartupError` so a
261
- * future Claude Code release that changes the dialog cannot silently break
262
- * fresh-spawn approval:
263
- * - `dev-channels-approve-no-dialog` — needle never observed
264
- * - `dev-channels-approve-still-visible` — needle persists after Enter
276
+ * These two seams shell out to tmux directly, keyed on the deterministic
277
+ * per-channel session name. Injectable so unit tests stay hermetic.
265
278
  */
266
- export async function approveDevChannelsDialog(
267
- channelId: string,
268
- web: WebClient | undefined,
269
- isStartup: boolean,
270
- normalizedName?: string,
271
- ): Promise<void> {
272
- void web
273
- const claude_instance_id = instanceIdFor(channelId, normalizedName)
274
- const deadline = Date.now() + _dialogPollTimeoutMs
279
+ export type TmuxPaneReader = (sessionName: string) => Promise<string>
280
+ export type TmuxEnterSender = (sessionName: string) => Promise<void>
275
281
 
276
- let approved = false
277
- while (Date.now() < deadline) {
278
- try {
279
- const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
280
- if (pane.includes(DEV_CHANNELS_DIALOG_NEEDLE)) {
281
- await withOutageDetection(channelId, undefined, (client) => client.sendKeys({ claude_instance_id, text: '', allow_pending: true }))
282
- approved = true
283
- break
282
+ function defaultTmuxCapturePane(sessionName: string): Promise<string> {
283
+ return (async () => {
284
+ const { spawn } = await import('child_process')
285
+ return await new Promise<string>((resolve) => {
286
+ try {
287
+ const child = spawn('tmux', ['capture-pane', '-p', '-t', sessionName])
288
+ let out = ''
289
+ child.stdout?.on('data', (d: Buffer) => { out += d.toString('utf8') })
290
+ child.on('error', () => resolve(''))
291
+ child.on('close', () => resolve(out))
292
+ } catch {
293
+ resolve('')
284
294
  }
285
- } catch (err) {
286
- if (err instanceof ErrSystemInstallDisappeared || err instanceof ErrTmuxNotAvailable) {
287
- // Outage flag raised; continue polling
288
- } else {
289
- console.error(`[slack] approveDevChannelsDialog: readPane error channel=${channelId}: ${String(err)}`)
295
+ })
296
+ })()
297
+ }
298
+
299
+ function defaultTmuxSendEnter(sessionName: string): Promise<void> {
300
+ return (async () => {
301
+ const { spawn } = await import('child_process')
302
+ await new Promise<void>((resolve) => {
303
+ try {
304
+ const child = spawn('tmux', ['send-keys', '-t', sessionName, 'Enter'], { stdio: 'ignore' })
305
+ child.on('error', () => resolve())
306
+ child.on('close', () => resolve())
307
+ } catch {
308
+ resolve()
290
309
  }
291
- }
292
- await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
293
- }
310
+ })
311
+ })()
312
+ }
294
313
 
295
- if (!approved) {
296
- const msg = `dev-channels dialog never appeared for channel=${channelId} within ${_dialogPollTimeoutMs}ms (dialog text may have drifted — see b.yy6)`
297
- console.error(`[slack] approveDevChannelsDialog: ${msg}`)
298
- if (isStartup) recordStartupError('dev-channels-approve-no-dialog', msg)
299
- return
300
- }
314
+ let _tmuxCapturePane: TmuxPaneReader = defaultTmuxCapturePane
315
+ let _tmuxSendEnter: TmuxEnterSender = defaultTmuxSendEnter
301
316
 
302
- let misses = 0
303
- while (Date.now() < deadline && misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
304
- await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
305
- try {
306
- const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
307
- misses = pane.includes(DEV_CHANNELS_DIALOG_NEEDLE) ? 0 : misses + 1
308
- } catch {
309
- /* tolerate transient readPane failure */
310
- }
311
- }
312
- if (misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
313
- const msg = `dev-channels dialog still visible after Enter for channel=${channelId} (sendKeys may not have reached pane)`
314
- console.error(`[slack] approveDevChannelsDialog: ${msg}`)
315
- if (isStartup) recordStartupError('dev-channels-approve-still-visible', msg)
316
- }
317
+ /** Test-only seam: override the raw tmux pane reader. */
318
+ export function _setTmuxCapturePane(fn: TmuxPaneReader): void {
319
+ _tmuxCapturePane = fn
320
+ }
321
+ /** Test-only seam: override the raw tmux Enter sender. */
322
+ export function _setTmuxSendEnter(fn: TmuxEnterSender): void {
323
+ _tmuxSendEnter = fn
324
+ }
325
+ /** Test-only seam: restore the default raw tmux helpers. */
326
+ export function _resetTmuxDialogHelpers(): void {
327
+ _tmuxCapturePane = defaultTmuxCapturePane
328
+ _tmuxSendEnter = defaultTmuxSendEnter
317
329
  }
318
330
 
319
- // ---------------------------------------------------------------------------
320
- // approveTrustFolderDialog — auto-approve the folder-trust dialog on spawn
321
- // ---------------------------------------------------------------------------
331
+ /** Live (post-SessionStart) states: the dialog is gone, the session is ready. */
332
+ const DIALOG_READY_STATES = new Set(['waiting', 'working', 'ask_user', 'check_permission'])
333
+ /** Terminal states: the spawn died before becoming ready. */
334
+ const DIALOG_DEAD_STATES = new Set(['ended', 'missing'])
335
+ /** Every pre-SessionStart dialog we can auto-approve (option 1 pre-selected; Enter accepts). */
336
+ const PRE_SESSION_DIALOG_NEEDLES = [TRUST_DIALOG_NEEDLE, DEV_CHANNELS_DIALOG_NEEDLE]
322
337
 
323
338
  /**
324
- * Verified against Claude Code 2.1.120 (2026-06-02). If this stops matching,
325
- * the folder-trust dialog has drifted — see b.k54 / b.uhv. Match the option
326
- * label (semantic, stable) rather than the header (cosmetic, drifts).
339
+ * b.vub: consecutive dead-state-with-no-needle polls required before treating a
340
+ * spawn as genuinely dead. A freshly resumed (or freshly spawned) row is
341
+ * legitimately `missing`/`ended` for a brief window: after `client.resume`, the
342
+ * tmux pane needs a moment to render the dev-channels dialog, and AD keeps
343
+ * reporting the prior terminal state until SessionStart re-fires. Bailing on the
344
+ * FIRST such poll (as the pre-b.vub code did) races the dialog and re-hangs the
345
+ * resume. Requiring a short grace streak lets the dialog appear (needle → Enter,
346
+ * which resets the streak) while still fast-failing a truly dead spawn without
347
+ * waiting the full 5-minute cap. Reset to 0 on any live state or needle sighting.
327
348
  */
328
- export const TRUST_DIALOG_NEEDLE = 'Yes, I trust this folder'
349
+ export const DIALOG_DEAD_GRACE_POLLS = 20
329
350
 
330
- let _trustDialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
331
- let _trustDialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
332
-
333
- /** Test-only seam: override the trust-folder poll interval. */
334
- export function _setTrustDialogPollIntervalMs(ms: number): void {
335
- _trustDialogPollIntervalMs = ms
336
- }
351
+ let _dialogDeadGracePolls = DIALOG_DEAD_GRACE_POLLS
337
352
 
338
- /** Test-only seam: restore the default trust-folder poll interval. */
339
- export function _resetTrustDialogPollIntervalMs(): void {
340
- _trustDialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
353
+ /** Test-only seam: override the dead-state grace streak. */
354
+ export function _setDialogDeadGracePolls(n: number): void {
355
+ _dialogDeadGracePolls = n
341
356
  }
342
357
 
343
- /** Test-only seam: override the trust-folder poll timeout. */
344
- export function _setTrustDialogPollTimeoutMs(ms: number): void {
345
- _trustDialogPollTimeoutMs = ms
346
- }
347
-
348
- /** Test-only seam: restore the default trust-folder poll timeout. */
349
- export function _resetTrustDialogPollTimeoutMs(): void {
350
- _trustDialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
358
+ /** Test-only seam: restore the default dead-state grace streak. */
359
+ export function _resetDialogDeadGracePolls(): void {
360
+ _dialogDeadGracePolls = DIALOG_DEAD_GRACE_POLLS
351
361
  }
352
362
 
353
363
  /**
354
- * Poll the freshly-spawned bot's tmux pane until the folder-trust dialog
355
- * appears, send Enter to accept the pre-selected "Yes, I trust this folder"
356
- * option, then confirm the dialog has cleared. All errors caught locally —
357
- * never throws to the caller.
364
+ * Drive a freshly-spawned bot past its pre-SessionStart dialogs (folder-trust
365
+ * and/or dev-channels) by watching agent-director's state machine: while the
366
+ * spawn is `pending` (SessionStart not yet fired — a dialog may be blocking),
367
+ * poll the pane and press Enter whenever a known dialog needle is on screen.
368
+ * Exit the instant AD reports a live state (the dialog is gone). 5-minute hard
369
+ * cap; failure to reach ready is surfaced loudly — never a silent early quit.
358
370
  *
359
- * readPane/sendKeys use `allow_pending: true` because the bot is still in
360
- * `pending` AD state when this runs (b.98w).
371
+ * status/readPane/sendKeys are wrapped in withOutageDetection so AD-outage
372
+ * flags keep working (b.en2). readPane/sendKeys use allow_pending:true because
373
+ * the bot is `pending` here (b.98w). The needle-gate guarantees Enter is sent
374
+ * ONLY when a dialog is actually displayed, so we never inject a stray Enter
375
+ * into a live prompt. Uses the composed instance id (b.ben).
361
376
  *
362
- * Divergence from `approveDevChannelsDialog`: if the needle never appears
363
- * within the timeout, return silently (expected happy path once Fix A is in
364
- * place — no `recordStartupError`). Only record an error when the needle
365
- * persists after Enter:
366
- * - `trust-folder-approve-still-visible` — needle persists after Enter
377
+ * Replaces the former two-function pair (trust-folder + dev-channels approvers):
378
+ * one loop from spawn+0 (no wasted 30s trust window) that self-heals a missed
379
+ * Enter (state stays pending, needle reappears, next iteration presses again)
380
+ * and never gives up silently at 30s.
381
+ *
382
+ * b.vub: pane-first / state-tolerant, with a RAW-TMUX fallback for dead rows.
383
+ * A *resumed* bot faces the same `--dangerously-load-development-channels`
384
+ * dialog, but its AD row is `missing`/`ended` from the prior life while it sits
385
+ * blocked at the dialog (SessionStart never re-fires). Two compounding facts the
386
+ * pre-b.vub code missed: (1) it early-returned on DIALOG_DEAD_STATES before ever
387
+ * reading the pane; (2) even if it had tried, agent-director's read-pane/
388
+ * send-keys REFUSE a `missing`/`ended` row (ErrSpawnNotInteractive), so the
389
+ * dialog can only be cleared by talking to tmux directly. Fix: within the
390
+ * pre-SessionStart window, for a dead row read+Enter via raw tmux (keyed on the
391
+ * deterministic session name), for a pending row via AD; press Enter whenever a
392
+ * needle is visible regardless of AD state; treat missing/ended as terminal only
393
+ * after a short no-needle grace streak (DIALOG_DEAD_GRACE_POLLS), tolerating the
394
+ * brief post-resume window before the dialog renders. Still needle-gated (no
395
+ * stray Enter into a live session) and bounded by the hard cap.
367
396
  */
368
- export async function approveTrustFolderDialog(
397
+ export async function approvePreSessionDialogs(
369
398
  channelId: string,
370
399
  web: WebClient | undefined,
371
400
  isStartup: boolean,
372
401
  normalizedName?: string,
373
402
  ): Promise<void> {
374
- void web
375
403
  const claude_instance_id = instanceIdFor(channelId, normalizedName)
376
- const deadline = Date.now() + _trustDialogPollTimeoutMs
404
+ const deadline = Date.now() + _dialogReadyTimeoutMs
405
+ let deadStreak = 0
377
406
 
378
- let approved = false
379
407
  while (Date.now() < deadline) {
408
+ // 1) Readiness oracle.
409
+ let state: string
380
410
  try {
381
- const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
382
- if (pane.includes(TRUST_DIALOG_NEEDLE)) {
383
- await withOutageDetection(channelId, undefined, (client) => client.sendKeys({ claude_instance_id, text: '', allow_pending: true }))
384
- approved = true
385
- break
386
- }
411
+ const r = await withOutageDetection(channelId, undefined, (client) => client.status({ claude_instance_id }))
412
+ state = r.state
387
413
  } catch (err) {
388
- if (err instanceof ErrSystemInstallDisappeared || err instanceof ErrTmuxNotAvailable) {
389
- // Outage flag raised; continue polling
390
- } else {
391
- console.error(`[slack] approveTrustFolderDialog: readPane error channel=${channelId}: ${String(err)}`)
414
+ if (err instanceof ErrSpawnNotFound) {
415
+ console.error(`[slack] approvePreSessionDialogs: spawn not found for channel=${channelId} — aborting`)
416
+ return
417
+ }
418
+ // Transient (incl. AD-outage errors already flagged by withOutageDetection) — keep polling.
419
+ console.error(`[slack] approvePreSessionDialogs: status error channel=${channelId}: ${String(err)}`)
420
+ await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
421
+ continue
422
+ }
423
+ if (DIALOG_READY_STATES.has(state)) return // dialog cleared, session live
424
+
425
+ // 2) Pane-first (b.vub): read the pane and dismiss any visible pre-session
426
+ // dialog BEFORE deciding whether a dead state is terminal.
427
+ //
428
+ // Two paths, because agent-director's read-pane/send-keys REFUSE a
429
+ // `missing`/`ended` row (ErrSpawnNotInteractive) even with allow_pending:
430
+ // - Fresh spawn (state=pending): drive via AD read-pane/send-keys.
431
+ // - Resumed row (state=missing/ended): AD won't touch the pane, but the
432
+ // dialog IS on screen and blocking SessionStart. Fall back to RAW tmux
433
+ // (capture-pane + send-keys Enter) keyed on the deterministic session
434
+ // name — exactly the `tmux send-keys -t <session> Enter` the operator
435
+ // ran by hand in the ticket. Once Enter lands, SessionStart fires and
436
+ // AD flips to a live state on the next poll.
437
+ let needleVisible = false
438
+ if (DIALOG_DEAD_STATES.has(state)) {
439
+ // Raw-tmux fallback (agent-director cannot interact with a dead row).
440
+ const sessionName = tmuxSessionNameFor(channelId, normalizedName)
441
+ try {
442
+ const pane = await _tmuxCapturePane(sessionName)
443
+ needleVisible = PRE_SESSION_DIALOG_NEEDLES.some((n) => pane.includes(n))
444
+ if (needleVisible) {
445
+ await _tmuxSendEnter(sessionName)
446
+ }
447
+ } catch (err) {
448
+ console.error(`[slack] approvePreSessionDialogs: raw-tmux fallback error channel=${channelId}: ${String(err)}`)
449
+ }
450
+ } else {
451
+ // Interactive (pending) — drive via agent-director.
452
+ try {
453
+ const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
454
+ needleVisible = PRE_SESSION_DIALOG_NEEDLES.some((n) => pane.includes(n))
455
+ if (needleVisible) {
456
+ await withOutageDetection(channelId, undefined, (client) => client.sendKeys({ claude_instance_id, text: '', allow_pending: true })) // Enter
457
+ }
458
+ } catch (err) {
459
+ console.error(`[slack] approvePreSessionDialogs: readPane/sendKeys error channel=${channelId}: ${String(err)}`)
392
460
  }
393
461
  }
394
- await new Promise((r) => setTimeout(r, _trustDialogPollIntervalMs))
395
- }
396
-
397
- if (!approved) {
398
- // Needle never appeared — silent return (expected happy path with Fix A)
399
- return
400
- }
401
462
 
402
- let misses = 0
403
- while (Date.now() < deadline && misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
404
- await new Promise((r) => setTimeout(r, _trustDialogPollIntervalMs))
405
- try {
406
- const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
407
- misses = pane.includes(TRUST_DIALOG_NEEDLE) ? 0 : misses + 1
408
- } catch {
409
- /* tolerate transient readPane failure */
463
+ // 3) Dead-state handling (b.vub). A needle means "dialog-blocked, not dead":
464
+ // reset the streak and keep pressing Enter. Only treat missing/ended as
465
+ // terminal after it persists with NO needle for DIALOG_DEAD_GRACE_POLLS
466
+ // consecutive polls — this tolerates the brief post-spawn/post-resume window
467
+ // where the row is still terminal and the pane hasn't rendered the dialog
468
+ // yet, without racing the dialog and re-hanging the resume.
469
+ if (needleVisible) {
470
+ deadStreak = 0
471
+ } else if (DIALOG_DEAD_STATES.has(state)) {
472
+ deadStreak += 1
473
+ if (deadStreak >= _dialogDeadGracePolls) {
474
+ const msg = `spawn reached ${state} before clearing dev-channels dialog for channel=${channelId} (no needle for ${deadStreak} polls)`
475
+ console.error(`[slack] approvePreSessionDialogs: ${msg}`)
476
+ if (isStartup) recordStartupError('dev-channels-approve-spawn-died', msg)
477
+ return
478
+ }
479
+ } else {
480
+ // pending (or any other non-dead, non-ready state) — reset the streak.
481
+ deadStreak = 0
410
482
  }
483
+
484
+ await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
411
485
  }
412
- if (misses < DIALOG_GONE_CONFIRMS_REQUIRED) {
413
- const msg = `trust-folder dialog still visible after Enter for channel=${channelId} (sendKeys may not have reached pane)`
414
- console.error(`[slack] approveTrustFolderDialog: ${msg}`)
415
- if (isStartup) recordStartupError('trust-folder-approve-still-visible', msg)
416
- }
486
+
487
+ // 3) Hard cap hit — genuine failure, surfaced loudly (no silent give-up).
488
+ const msg = `spawn never reached a live state within ${_dialogReadyTimeoutMs}ms for channel=${channelId} — dialog unrecognized or session hung (dev-needle='${DEV_CHANNELS_DIALOG_NEEDLE}')`
489
+ console.error(`[slack] approvePreSessionDialogs: ${msg}`)
490
+ if (isStartup) recordStartupError('dev-channels-approve-not-ready', msg)
491
+ postSpawnFailureToChannel(channelId, new AgentDirectorError('status', 'DialogApprovalTimeout', msg), web, isStartup)
417
492
  }
418
493
 
419
494
  // ---------------------------------------------------------------------------
@@ -530,6 +605,78 @@ async function tryKill(channelId: string, normalizedName: string | undefined): P
530
605
  }
531
606
  }
532
607
 
608
+ // ---------------------------------------------------------------------------
609
+ // Orphan-tmux self-heal (b.vub)
610
+ // ---------------------------------------------------------------------------
611
+
612
+ /**
613
+ * Kill a tmux session by its exact name. Injectable seam so unit tests can
614
+ * assert the self-heal path without spawning real processes. Default impl
615
+ * runs `tmux kill-session -t <name>` best-effort.
616
+ *
617
+ * b.vub: the field failure is an orphan tmux session that survives the AD row
618
+ * going `missing` — the AD `client.kill` verb does NOT reap it (observed across
619
+ * dozens of restart cycles). Killing the session directly by its deterministic,
620
+ * per-channel name (`tmuxSessionNameFor`) is the only reliable reap, and the
621
+ * name can only ever belong to this channel's spawn, so it is safe.
622
+ */
623
+ export type TmuxSessionKiller = (sessionName: string) => Promise<void>
624
+
625
+ let _killTmuxSession: TmuxSessionKiller = async (sessionName: string): Promise<void> => {
626
+ const { spawn } = await import('child_process')
627
+ await new Promise<void>((resolve) => {
628
+ try {
629
+ const child = spawn('tmux', ['kill-session', '-t', sessionName], { stdio: 'ignore' })
630
+ child.on('error', () => resolve()) // tmux missing / session absent — best-effort
631
+ child.on('close', () => resolve())
632
+ } catch {
633
+ resolve()
634
+ }
635
+ })
636
+ }
637
+
638
+ /** Test-only seam: override the tmux-session killer. */
639
+ export function _setTmuxSessionKiller(fn: TmuxSessionKiller): void {
640
+ _killTmuxSession = fn
641
+ }
642
+
643
+ /** Test-only seam: restore the default tmux-session killer. */
644
+ export function _resetTmuxSessionKiller(): void {
645
+ _killTmuxSession = async (sessionName: string): Promise<void> => {
646
+ const { spawn } = await import('child_process')
647
+ await new Promise<void>((resolve) => {
648
+ try {
649
+ const child = spawn('tmux', ['kill-session', '-t', sessionName], { stdio: 'ignore' })
650
+ child.on('error', () => resolve())
651
+ child.on('close', () => resolve())
652
+ } catch {
653
+ resolve()
654
+ }
655
+ })
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Self-heal an `ErrTmuxSessionCreate` collision (b.vub): the deterministic tmux
661
+ * session name is still held by an orphaned session while the AD row is
662
+ * terminal/gone, so a fresh spawn/resume cannot create the session. Kill the
663
+ * orphan by name, then retry `client.spawn` ONCE. Returns the spawn result on
664
+ * success, or rethrows the retry's error (caller surfaces it).
665
+ */
666
+ async function selfHealTmuxCollisionAndRespawn(
667
+ channelId: string,
668
+ route: { cwd: string },
669
+ params: SpawnParams,
670
+ normalizedName: string | undefined,
671
+ ): Promise<{ claude_instance_id: string }> {
672
+ const sessionName = tmuxSessionNameFor(channelId, normalizedName)
673
+ console.error(
674
+ `[slack] spawnForRoute: ErrTmuxSessionCreate for channel=${channelId} — killing orphan tmux session "${sessionName}" and retrying spawn once`,
675
+ )
676
+ await _killTmuxSession(sessionName)
677
+ return withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
678
+ }
679
+
533
680
  /** Delete the spawn row; surface failures. Returns whether the delete succeeded. */
534
681
  async function tryDelete(
535
682
  channelId: string,
@@ -585,13 +732,36 @@ export async function spawnForRoute(
585
732
  try {
586
733
  const r = await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
587
734
  console.error(`[slack] spawnForRoute: spawned channel=${channelId} instanceId=${r.claude_instance_id}`)
588
- await approveTrustFolderDialog(channelId, web, isStartup, normalizedName)
589
- await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
735
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
590
736
  return { channelId, action: 'spawned' }
591
737
  } catch (err) {
592
738
  if (err instanceof ErrInstanceIdCollision) {
593
739
  // Collision → fall through to get-then-act
594
740
  console.error(`[slack] spawnForRoute: ErrInstanceIdCollision for channel=${channelId} — fetching current state`)
741
+ } else if (err instanceof ErrTmuxSessionCreate) {
742
+ // b.vub: fresh spawn collided on the deterministic tmux session name held
743
+ // by an orphan session (no instance-id collision → no AD row to resolve).
744
+ // Self-heal: kill the orphan by name, retry spawn once.
745
+ try {
746
+ const r = await selfHealTmuxCollisionAndRespawn(channelId, route, params, normalizedName)
747
+ console.error(`[slack] spawnForRoute: self-heal spawn succeeded after ErrTmuxSessionCreate for channel=${channelId} instanceId=${r.claude_instance_id}`)
748
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
749
+ return { channelId, action: 'spawned' }
750
+ } catch (err2) {
751
+ if (
752
+ err2 instanceof ErrSystemInstallDisappeared ||
753
+ err2 instanceof ErrTmuxNotAvailable ||
754
+ err2 instanceof ErrCwdNotFound ||
755
+ err2 instanceof ErrCwdNotADirectory
756
+ ) {
757
+ return { channelId, action: 'failed' }
758
+ }
759
+ const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
760
+ console.error(`[slack] spawnForRoute: self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`)
761
+ if (isStartup) recordStartupError('spawn-failed', `self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`, e)
762
+ postSpawnFailureToChannel(channelId, e, web, isStartup)
763
+ return { channelId, action: 'failed' }
764
+ }
595
765
  } else if (
596
766
  err instanceof ErrSystemInstallDisappeared ||
597
767
  err instanceof ErrTmuxNotAvailable ||
@@ -620,8 +790,7 @@ export async function spawnForRoute(
620
790
  try {
621
791
  const r = await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
622
792
  console.error(`[slack] spawnForRoute: retry-spawn succeeded for channel=${channelId} instanceId=${r.claude_instance_id}`)
623
- await approveTrustFolderDialog(channelId, web, isStartup, normalizedName)
624
- await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
793
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
625
794
  return { channelId, action: 'spawned' }
626
795
  } catch (err2) {
627
796
  if (
@@ -658,8 +827,7 @@ export async function spawnForRoute(
658
827
  try {
659
828
  await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
660
829
  console.error(`[slack] spawnForRoute: fresh-spawned (after kill+delete) for channel=${channelId}`)
661
- await approveTrustFolderDialog(channelId, web, isStartup, normalizedName)
662
- await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
830
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
663
831
  return { channelId, action: 'spawned' }
664
832
  } catch (err) {
665
833
  if (
@@ -683,16 +851,45 @@ export async function spawnForRoute(
683
851
  try {
684
852
  await withSpawnDetection(channelId, route.cwd, (client) => client.resume({ claude_instance_id: instanceIdFor(channelId, normalizedName) }))
685
853
  console.error(`[slack] spawnForRoute: resumed channel=${channelId}`)
854
+ // b.vub: a resumed bot faces the same --dangerously-load-development-channels
855
+ // dialog. Its AD row is still `missing`/`ended` while blocked at the dialog
856
+ // (SessionStart hasn't re-fired), so the pane-first approver drives it past.
857
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
686
858
  return { channelId, action: 'resumed' }
687
859
  } catch (err) {
860
+ if (err instanceof ErrTmuxSessionCreate) {
861
+ // b.vub: the deterministic tmux session name is still held by an orphan
862
+ // session while the AD row is terminal — resume cannot re-create it.
863
+ // This is the observed field failure (resume throws ErrTmuxSessionCreate
864
+ // every ~2 min). Self-heal: kill the orphan by name, retry spawn once.
865
+ try {
866
+ const r = await selfHealTmuxCollisionAndRespawn(channelId, route, params, normalizedName)
867
+ console.error(`[slack] spawnForRoute: self-heal spawn succeeded after ErrTmuxSessionCreate for channel=${channelId} instanceId=${r.claude_instance_id}`)
868
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
869
+ return { channelId, action: 'spawned' }
870
+ } catch (err2) {
871
+ if (
872
+ err2 instanceof ErrSystemInstallDisappeared ||
873
+ err2 instanceof ErrTmuxNotAvailable ||
874
+ err2 instanceof ErrCwdNotFound ||
875
+ err2 instanceof ErrCwdNotADirectory
876
+ ) {
877
+ return { channelId, action: 'failed' }
878
+ }
879
+ const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
880
+ console.error(`[slack] spawnForRoute: self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`)
881
+ if (isStartup) recordStartupError('spawn-failed', `self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`, e)
882
+ postSpawnFailureToChannel(channelId, e, web, isStartup)
883
+ return { channelId, action: 'failed' }
884
+ }
885
+ }
688
886
  if (err instanceof ErrNoSessionId || err instanceof ErrJsonlMissing) {
689
887
  console.error(`[slack] spawnForRoute: ${err.errName} on resume for channel=${channelId} — delete+fresh`)
690
888
  if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
691
889
  try {
692
890
  await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
693
891
  console.error(`[slack] spawnForRoute: fresh-spawned (after delete) for channel=${channelId}`)
694
- await approveTrustFolderDialog(channelId, web, isStartup, normalizedName)
695
- await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
892
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
696
893
  return { channelId, action: 'spawned' }
697
894
  } catch (err2) {
698
895
  if (
@@ -717,8 +914,7 @@ export async function spawnForRoute(
717
914
  if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
718
915
  try {
719
916
  await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
720
- await approveTrustFolderDialog(channelId, web, isStartup, normalizedName)
721
- await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
917
+ await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
722
918
  return { channelId, action: 'spawned' }
723
919
  } catch (err2) {
724
920
  if (