diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 93baa606..f6647fbd 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -429,6 +429,29 @@ func (e *Executor) IsSuspended(taskID int64) bool { return suspended } +// agentSendTargetForPane returns the tmux send-keys target for a task's agent +// pane. It prefers the persisted pane id (the stable "%pane_id" captured at +// window creation and used by the UI for capture) so input is never +// misdelivered when the detail view has joined the agent pane into the UI +// session — which collapses the shell pane onto window index 0. Falls back to +// windowTarget+".0" when no pane id has been persisted yet. +func agentSendTargetForPane(claudePaneID, windowTarget string) string { + if claudePaneID != "" { + return claudePaneID + } + return windowTarget + ".0" +} + +// agentSendTarget resolves the send-keys target for a task's agent pane, +// reading the persisted pane id from the database. See agentSendTargetForPane. +func (e *Executor) agentSendTarget(taskID int64, windowTarget string) string { + claudePaneID := "" + if t, err := e.db.GetTask(taskID); err == nil && t != nil { + claudePaneID = t.ClaudePaneID + } + return agentSendTargetForPane(claudePaneID, windowTarget) +} + // findPanesForWindow parses tmux list-panes output and returns PIDs for panes // in windows matching the given name exactly. The input format is one line per pane: // @@ -2668,7 +2691,7 @@ func (e *Executor) resumeClaudeDangerous(task *db.Task, workDir string) bool { // Automatically send "continue working" to resume the task // This tells Claude to continue where it left off after the mode switch - exec.Command("tmux", "send-keys", "-t", windowTarget+".0", "continue working", "Enter").Run() + exec.Command("tmux", "send-keys", "-t", e.agentSendTarget(task.ID, windowTarget), "continue working", "Enter").Run() e.logLine(taskID, "system", "Sent 'continue working' to resume task") // Don't poll for completion here - the process will continue running in tmux @@ -2833,7 +2856,7 @@ func (e *Executor) resumeClaudeSafe(task *db.Task, workDir string) bool { // Automatically send "continue working" to resume the task // This tells Claude to continue where it left off after the mode switch - exec.Command("tmux", "send-keys", "-t", windowTarget+".0", "continue working", "Enter").Run() + exec.Command("tmux", "send-keys", "-t", e.agentSendTarget(task.ID, windowTarget), "continue working", "Enter").Run() e.logLine(taskID, "system", "Sent 'continue working' to resume task") // Don't poll for completion here - the process will continue running in tmux @@ -2939,7 +2962,7 @@ func (e *Executor) resumeCodexWithMode(task *db.Task, workDir string, dangerousM // Automatically send "continue working" to resume the task // This tells Codex to continue where it left off after the mode switch - exec.Command("tmux", "send-keys", "-t", windowTarget+".0", "continue working", "Enter").Run() + exec.Command("tmux", "send-keys", "-t", e.agentSendTarget(task.ID, windowTarget), "continue working", "Enter").Run() e.logLine(taskID, "system", "Sent 'continue working' to resume task") return true @@ -3047,7 +3070,7 @@ func (e *Executor) resumeGeminiWithMode(task *db.Task, workDir string, dangerous // Automatically send "continue working" to resume the task // This tells Gemini to continue where it left off after the mode switch - exec.Command("tmux", "send-keys", "-t", windowTarget+".0", "continue working", "Enter").Run() + exec.Command("tmux", "send-keys", "-t", e.agentSendTarget(task.ID, windowTarget), "continue working", "Enter").Run() e.logLine(taskID, "system", "Sent 'continue working' to resume task") return true diff --git a/internal/executor/tmux_send_target_test.go b/internal/executor/tmux_send_target_test.go new file mode 100644 index 00000000..cea9a3d2 --- /dev/null +++ b/internal/executor/tmux_send_target_test.go @@ -0,0 +1,41 @@ +package executor + +import ( + "testing" +) + +// When the detail view joins the agent pane into the UI session, the shell pane +// collapses onto window index 0. Sending to windowTarget+".0" then misdelivers +// input to the shell and the agent starves. agentSendTargetForPane must prefer +// the persisted, stable pane id (the same one the UI uses for capture). +func TestAgentSendTargetForPane(t *testing.T) { + tests := []struct { + name string + claudePaneID string + windowTarget string + want string + }{ + { + name: "persisted pane id is used, never window .0", + claudePaneID: "%3412", + windowTarget: "task-daemon-123:task-5", + want: "%3412", + }, + { + name: "no persisted pane id -> fall back to window .0", + claudePaneID: "", + windowTarget: "task-daemon-123:task-5", + want: "task-daemon-123:task-5.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := agentSendTargetForPane(tt.claudePaneID, tt.windowTarget) + if got != tt.want { + t.Errorf("agentSendTargetForPane(%q, %q) = %q, want %q", + tt.claudePaneID, tt.windowTarget, got, tt.want) + } + }) + } +}