feat: runtime-aware tmux agent checks

This commit is contained in:
jv
2026-01-07 12:56:00 +13:00
committed by Steve Yegge
parent 02ca9e43fa
commit 22693c1dcc
9 changed files with 117 additions and 46 deletions

View File

@@ -15,8 +15,8 @@ import (
// Common errors
var (
ErrNoServer = errors.New("no tmux server running")
ErrSessionExists = errors.New("session already exists")
ErrNoServer = errors.New("no tmux server running")
ErrSessionExists = errors.New("session already exists")
ErrSessionNotFound = errors.New("session not found")
)
@@ -94,7 +94,7 @@ func (t *Tmux) EnsureSessionFresh(name, workDir string) error {
if exists {
// Session exists - check if it's a zombie
if !t.IsClaudeRunning(name) {
if !t.IsAgentRunning(name) {
// Zombie session: tmux alive but Claude dead
// Kill it so we can create a fresh one
if err := t.KillSession(name); err != nil {
@@ -390,8 +390,8 @@ 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 checkClaude is true, only returns sessions that have Claude (node) running.
func (t *Tmux) FindSessionByWorkDir(targetDir string, checkClaude bool) ([]string, error) {
// If requireAgentRunning is true, only returns sessions that have some non-shell command running.
func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool) ([]string, error) {
sessions, err := t.ListSessions()
if err != nil {
return nil, err
@@ -410,9 +410,9 @@ func (t *Tmux) FindSessionByWorkDir(targetDir string, checkClaude bool) ([]strin
// Check if workdir matches target (exact match or subdir)
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
if checkClaude {
// Only include if Claude is running
if t.IsClaudeRunning(session) {
if requireAgentRunning {
// Only include if an agent appears to be running
if t.IsAgentRunning(session) {
matches = append(matches, session)
}
} else {
@@ -526,15 +526,39 @@ Run: gt mail inbox
return t.SendKeys(session, banner)
}
// IsClaudeRunning checks if Claude appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
func (t *Tmux) IsClaudeRunning(session string) bool {
// Check pane command - Claude runs as node
// IsAgentRunning checks if an agent appears to be running in the session.
//
// If expectedPaneCommands is non-empty, the pane's current command must match one of them.
// If expectedPaneCommands is empty, any non-shell command counts as "agent running".
func (t *Tmux) IsAgentRunning(session string, expectedPaneCommands ...string) bool {
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
return cmd == "node"
if len(expectedPaneCommands) > 0 {
for _, expected := range expectedPaneCommands {
if expected != "" && cmd == expected {
return true
}
}
return false
}
// Fallback: any non-shell command counts as running.
for _, shell := range constants.SupportedShells {
if cmd == shell {
return false
}
}
return cmd != ""
}
// IsClaudeRunning checks if Claude appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
func (t *Tmux) IsClaudeRunning(session string) bool {
// Claude runs as node
return t.IsAgentRunning(session, "node")
}
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
@@ -595,14 +619,16 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
//
// Bootstrap (acceptable):
// During cold startup when no AI agent is running, the daemon uses this
// function to get the Deacon online. Regex is acceptable here.
//
// During cold startup when no AI agent is running, the daemon uses this
// function to get the Deacon online. Regex is acceptable here.
//
// Steady-State (use AI observation instead):
// Once any AI agent is running, observation should be AI-to-AI:
// - Deacon starting polecats → use 'gt deacon pending' + AI analysis
// - Deacon restarting → Mayor watches via 'gt peek'
// - Mayor restarting → Deacon watches via 'gt peek'
//
// Once any AI agent is running, observation should be AI-to-AI:
// - Deacon starting polecats → use 'gt deacon pending' + AI analysis
// - Deacon restarting → Mayor watches via 'gt peek'
// - Mayor restarting → Deacon watches via 'gt peek'
//
// See: gt deacon pending (ZFC-compliant AI observation)
// See: gt deacon trigger-pending (bootstrap mode, regex-based)

View File

@@ -260,6 +260,11 @@ func TestEnsureSessionFresh_ZombieSession(t *testing.T) {
t.Skip("session unexpectedly has Claude running - can't test zombie case")
}
// Verify generic agent check also treats it as not running (shell session)
if tm.IsAgentRunning(sessionName) {
t.Fatalf("expected IsAgentRunning(%q) to be false for a fresh shell session", sessionName)
}
// EnsureSessionFresh should kill the zombie and create fresh session
// This should NOT error with "session already exists"
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {