From 28a9de64d5dfd3202b92a2d297acdac26278e8a1 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 7 Jan 2026 20:49:25 -0800 Subject: [PATCH] fix(tmux): wait for shell ready before sending keys (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WaitForShellReady call before SendKeys in all agent managers (deacon, mayor, witness, refinery). This prevents intermittent "can't find pane" errors that occur when the tmux session is created but the shell isn't ready to receive input yet. The issue manifests under load (e.g., during `gt up` when multiple agents start in sequence) where the 200ms delay in SendKeysDelayed isn't sufficient for the pane to be fully initialized. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- internal/boot/boot.go | 5 +++++ internal/deacon/manager.go | 5 +++++ internal/mayor/manager.go | 5 +++++ internal/polecat/session_manager.go | 5 +++++ internal/refinery/manager.go | 5 +++++ internal/witness/manager.go | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 52f22c84..2b1a42f0 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -197,6 +197,11 @@ func (b *Boot) spawnTmux() error { // Launch Claude with environment exported inline and initial triage prompt // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage") + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := b.tmux.WaitForShellReady(SessionName, 5*time.Second); err != nil { + _ = b.tmux.KillSession(SessionName) + return fmt.Errorf("waiting for shell: %w", err) + } if err := b.tmux.SendKeys(SessionName, startCmd); err != nil { return fmt.Errorf("sending startup command: %w", err) } diff --git a/internal/deacon/manager.go b/internal/deacon/manager.go index 8d0f2262..43fda759 100644 --- a/internal/deacon/manager.go +++ b/internal/deacon/manager.go @@ -99,6 +99,11 @@ func (m *Manager) Start() error { runtimeCmd, ) + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = t.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } if err := t.SendKeysDelayed(sessionID, respawnCmd, 200); err != nil { _ = t.KillSession(sessionID) // best-effort cleanup return fmt.Errorf("starting Claude agent: %w", err) diff --git a/internal/mayor/manager.go b/internal/mayor/manager.go index 62b74085..3a6a985b 100644 --- a/internal/mayor/manager.go +++ b/internal/mayor/manager.go @@ -98,6 +98,11 @@ func (m *Manager) Start(agentOverride string) error { _ = t.KillSession(sessionID) // best-effort cleanup return fmt.Errorf("building startup command: %w", err) } + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = t.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } if err := t.SendKeysDelayed(sessionID, startupCmd, 200); err != nil { _ = t.KillSession(sessionID) // best-effort cleanup return fmt.Errorf("starting Claude agent: %w", err) diff --git a/internal/polecat/session_manager.go b/internal/polecat/session_manager.go index e24d8537..bc4e86a5 100644 --- a/internal/polecat/session_manager.go +++ b/internal/polecat/session_manager.go @@ -183,6 +183,11 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error { if command == "" { command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "") } + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = m.tmux.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } if err := m.tmux.SendKeys(sessionID, command); err != nil { return fmt.Errorf("sending command: %w", err) } diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 75302161..3f2262b4 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -210,6 +210,11 @@ func (m *Manager) Start(foreground bool) error { // Restarts are handled by daemon via LIFECYCLE mail, not shell loops // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes command := config.BuildAgentStartupCommand("refinery", bdActor, m.rig.Path, "") + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = t.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } if err := t.SendKeys(sessionID, command); err != nil { // Clean up the session on failure (best-effort cleanup) _ = t.KillSession(sessionID) diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 3d67eff1..884048e8 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -183,6 +183,11 @@ func (m *Manager) Start(foreground bool) error { // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // Pass m.rig.Path so rig agent settings are honored (not town-level defaults) command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "") + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = t.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } if err := t.SendKeys(sessionID, command); err != nil { _ = t.KillSession(sessionID) // best-effort cleanup return fmt.Errorf("starting Claude agent: %w", err)