fix(tmux): wake Claude in detached sessions by triggering SIGWINCH (#976)

Add functions to wake Claude Code's event loop in detached tmux sessions:

- IsSessionAttached: Check if session has attached clients
- WakePane: Always trigger SIGWINCH via resize dance
- WakePaneIfDetached: Smart wrapper that skips attached sessions

When Claude runs in a detached tmux session, its TUI library may not
process stdin until a terminal event occurs. Attaching triggers SIGWINCH
which wakes the event loop. WakePane simulates that by resizing the pane
down 1 row then back up.

NudgeSession and NudgePane now call WakePaneIfDetached after sending
Enter, covering all 22 nudge call sites in the codebase.

Fixes: gt-6s75ln

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
aleiby
2026-01-25 18:02:30 -08:00
committed by GitHub
parent 57f062a9b6
commit fe09e59c8c

View File

@@ -721,9 +721,43 @@ func getSessionNudgeLock(session string) *sync.Mutex {
return actual.(*sync.Mutex)
}
// IsSessionAttached returns true if the session has any clients attached.
func (t *Tmux) IsSessionAttached(target string) bool {
attached, err := t.run("display-message", "-t", target, "-p", "#{session_attached}")
return err == nil && attached == "1"
}
// WakePane triggers a SIGWINCH in a pane by resizing it slightly then restoring.
// This wakes up Claude Code's event loop by simulating a terminal resize.
//
// When Claude runs in a detached tmux session, its TUI library may not process
// stdin until a terminal event occurs. Attaching triggers SIGWINCH which wakes
// the event loop. This function simulates that by doing a resize dance.
//
// Note: This always performs the resize. Use WakePaneIfDetached to skip
// attached sessions where the wake is unnecessary.
func (t *Tmux) WakePane(target string) {
// Resize pane down by 1 row, then up by 1 row
// This triggers SIGWINCH without changing the final pane size
_, _ = t.run("resize-pane", "-t", target, "-y", "-1")
time.Sleep(50 * time.Millisecond)
_, _ = t.run("resize-pane", "-t", target, "-y", "+1")
}
// WakePaneIfDetached triggers a SIGWINCH only if the session is detached.
// This avoids unnecessary latency on attached sessions where Claude is
// already processing terminal events.
func (t *Tmux) WakePaneIfDetached(target string) {
if t.IsSessionAttached(target) {
return
}
t.WakePane(target)
}
// NudgeSession sends a message to a Claude Code session reliably.
// This is the canonical way to send messages to Claude sessions.
// Uses: literal mode + 500ms debounce + ESC (for vim mode) + separate Enter.
// After sending, triggers SIGWINCH to wake Claude in detached sessions.
// Verification is the Witness's job (AI), not this function.
//
// IMPORTANT: Nudges to the same session are serialized to prevent interleaving.
@@ -759,6 +793,8 @@ func (t *Tmux) NudgeSession(session, message string) error {
lastErr = err
continue
}
// 5. Wake the pane to trigger SIGWINCH for detached sessions
t.WakePaneIfDetached(session)
return nil
}
return fmt.Errorf("failed to send Enter after 3 attempts: %w", lastErr)
@@ -766,6 +802,7 @@ func (t *Tmux) NudgeSession(session, message string) error {
// NudgePane sends a message to a specific pane reliably.
// Same pattern as NudgeSession but targets a pane ID (e.g., "%9") instead of session name.
// After sending, triggers SIGWINCH to wake Claude in detached sessions.
// Nudges to the same pane are serialized to prevent interleaving.
func (t *Tmux) NudgePane(pane, message string) error {
// Serialize nudges to this pane to prevent interleaving
@@ -796,6 +833,8 @@ func (t *Tmux) NudgePane(pane, message string) error {
lastErr = err
continue
}
// 5. Wake the pane to trigger SIGWINCH for detached sessions
t.WakePaneIfDetached(pane)
return nil
}
return fmt.Errorf("failed to send Enter after 3 attempts: %w", lastErr)