From e344e779214cafd92c33589a03c71cc84f0286fd Mon Sep 17 00:00:00 2001 From: aleiby Date: Fri, 16 Jan 2026 15:25:49 -0800 Subject: [PATCH] fix(tmux): serialize nudges to prevent interleaving (#571) When multiple agents start simultaneously (e.g., `gt up`), each runs `gt nudge deacon session-started` in their SessionStart hook. These nudges arrive concurrently and can interleave in the tmux input buffer, causing: 1. Text from one nudge mixing with another 2. Enter keys not properly submitting messages 3. Garbled input requiring manual intervention This fix adds per-session mutex serialization to NudgeSession() and NudgePane(). Concurrent nudges to the same session now queue and execute one at a time. ## Root Cause The NudgeSession pattern sends text, waits 500ms, sends Escape, waits 100ms, then sends Enter. When multiple nudges arrive within this ~800ms window, their send-keys commands interleave, corrupting the input. ## Alternatives Considered 1. **Delay deacon nudges** - Add sleep before nudge in SessionStart - Simplest (one-line change) - But: doesn't prevent concurrent nudges from multiple agents 2. **Debounce session-started** - Deacon ignores rapid-fire nudges - Medium complexity - But: only helps session-started, not other nudge types 3. **File-based signaling** - Replace tmux nudges with file watches - Avoids tmux input issues entirely - But: significant architectural change 4. **File upstream bug** - Report to Claude Code team - SessionStart hooks fire async and can interleave - But: fix timeline unknown, need robustness now ## Tradeoffs - Concurrent nudges to same session now queue (adds latency) - Memory: one mutex per unique session name (bounded, acceptable) - Does not fix Claude Code's underlying async hook behavior ## Testing - Build passes - All tmux package tests pass - Manual testing: started deacon + multiple witnesses concurrently, nudges processed correctly without garbled input Co-authored-by: Claude Opus 4.5 --- internal/tmux/tmux.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index fe2b097c..749106ea 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -9,12 +9,18 @@ import ( "os/exec" "regexp" "strings" + "sync" "time" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" ) +// sessionNudgeLocks serializes nudges to the same session. +// This prevents interleaving when multiple nudges arrive concurrently, +// which can cause garbled input and missed Enter keys. +var sessionNudgeLocks sync.Map // map[string]*sync.Mutex + // versionPattern matches Claude Code version numbers like "2.0.76" var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) @@ -420,11 +426,28 @@ func (t *Tmux) SendKeysDelayedDebounced(session, keys string, preDelayMs, deboun return t.SendKeysDebounced(session, keys, debounceMs) } +// getSessionNudgeLock returns the mutex for serializing nudges to a session. +// Creates a new mutex if one doesn't exist for this session. +func getSessionNudgeLock(session string) *sync.Mutex { + actual, _ := sessionNudgeLocks.LoadOrStore(session, &sync.Mutex{}) + return actual.(*sync.Mutex) +} + // 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. // Verification is the Witness's job (AI), not this function. +// +// IMPORTANT: Nudges to the same session are serialized to prevent interleaving. +// If multiple goroutines try to nudge the same session concurrently, they will +// queue up and execute one at a time. This prevents garbled input when +// SessionStart hooks and nudges arrive simultaneously. func (t *Tmux) NudgeSession(session, message string) error { + // Serialize nudges to this session to prevent interleaving + lock := getSessionNudgeLock(session) + lock.Lock() + defer lock.Unlock() + // 1. Send text in literal mode (handles special characters) if _, err := t.run("send-keys", "-t", session, "-l", message); err != nil { return err @@ -455,7 +478,13 @@ 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. +// 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 + lock := getSessionNudgeLock(pane) + lock.Lock() + defer lock.Unlock() + // 1. Send text in literal mode (handles special characters) if _, err := t.run("send-keys", "-t", pane, "-l", message); err != nil { return err