This commit is contained in:
Ben Kraus
2026-01-02 09:21:49 -07:00
committed by Cameron Palmer
parent f4cbcb4ce9
commit 38adfa4d8b
33 changed files with 1044 additions and 172 deletions

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
)
@@ -390,8 +391,9 @@ func (t *Tmux) GetPaneWorkDir(session string) (string, error) {
// FindSessionByWorkDir finds tmux sessions where the pane's current working directory
// matches or is under the target directory. Returns session names that match.
// If requireAgentRunning is true, only returns sessions that have some non-shell command running.
func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool) ([]string, error) {
// If processNames is provided, only returns sessions that match those processes.
// If processNames is nil or empty, returns all sessions matching the directory.
func (t *Tmux) FindSessionByWorkDir(targetDir string, processNames []string) ([]string, error) {
sessions, err := t.ListSessions()
if err != nil {
return nil, err
@@ -410,14 +412,13 @@ func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool)
// Check if workdir matches target (exact match or subdir)
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
if requireAgentRunning {
// Only include if an agent appears to be running
if t.IsAgentRunning(session) {
if len(processNames) > 0 {
if t.IsRuntimeRunning(session, processNames) {
matches = append(matches, session)
}
} else {
matches = append(matches, session)
continue
}
matches = append(matches, session)
}
}
@@ -561,6 +562,25 @@ func (t *Tmux) IsClaudeRunning(session string) bool {
return t.IsAgentRunning(session, "node")
}
// IsRuntimeRunning checks if a runtime appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
// This is the runtime-config-aware version of IsAgentRunning.
func (t *Tmux) IsRuntimeRunning(session string, processNames []string) bool {
if len(processNames) == 0 {
return false
}
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
for _, name := range processNames {
if cmd == name {
return true
}
}
return false
}
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
// Useful for waiting until a shell has started a new process (e.g., claude).
// Returns nil when a non-excluded command is detected, or error on timeout.
@@ -609,13 +629,12 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
return fmt.Errorf("timeout waiting for shell")
}
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
// Claude is ready when we see "> " at the start of a line (the input prompt).
// This is more reliable than just checking if node is running.
// WaitForRuntimeReady polls until the runtime's prompt indicator appears in the pane.
// Runtime is ready when we see the configured prompt prefix at the start of a line.
//
// IMPORTANT: Bootstrap vs Steady-State Observation
//
// This function uses regex to detect Claude's prompt - a ZFC violation.
// This function uses regex to detect runtime prompts - a ZFC violation.
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
//
// Bootstrap (acceptable):
@@ -632,7 +651,24 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
//
// See: gt deacon pending (ZFC-compliant AI observation)
// See: gt deacon trigger-pending (bootstrap mode, regex-based)
func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
func (t *Tmux) WaitForRuntimeReady(session string, rc *config.RuntimeConfig, timeout time.Duration) error {
if rc == nil || rc.Tmux == nil {
return nil
}
if rc.Tmux.ReadyPromptPrefix == "" {
if rc.Tmux.ReadyDelayMs <= 0 {
return nil
}
// Fallback to fixed delay when prompt detection is unavailable.
delay := time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond
if delay > timeout {
delay = timeout
}
time.Sleep(delay)
return nil
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
// Capture last few lines of the pane
@@ -641,16 +677,17 @@ func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
time.Sleep(200 * time.Millisecond)
continue
}
// Look for Claude's prompt indicator "> " at start of line
// Look for runtime prompt indicator at start of line
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "> ") || trimmed == ">" {
prefix := strings.TrimSpace(rc.Tmux.ReadyPromptPrefix)
if strings.HasPrefix(trimmed, rc.Tmux.ReadyPromptPrefix) || (prefix != "" && trimmed == prefix) {
return nil
}
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for Claude prompt")
return fmt.Errorf("timeout waiting for runtime prompt")
}
// GetSessionInfo returns detailed information about a session.