diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 218be94b..fb6d8d31 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -759,7 +759,7 @@ func runRigBoot(cmd *cobra.Command, args []string) error { } else { fmt.Printf(" Starting witness...\n") witMgr := witness.NewManager(r) - if err := witMgr.Start(false); err != nil { + if err := witMgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { skipped = append(skipped, "witness (already running)") } else { @@ -839,7 +839,7 @@ func runRigStart(cmd *cobra.Command, args []string) error { } else { fmt.Printf(" Starting witness...\n") witMgr := witness.NewManager(r) - if err := witMgr.Start(false); err != nil { + if err := witMgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { skipped = append(skipped, "witness") } else { @@ -1418,7 +1418,7 @@ func runRigRestart(cmd *cobra.Command, args []string) error { skipped = append(skipped, "witness") } else { fmt.Printf(" Starting witness...\n") - if err := witMgr.Start(false); err != nil { + if err := witMgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { skipped = append(skipped, "witness") } else { diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 3a0321b0..47770e98 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -235,7 +235,7 @@ func startRigAgents(t *tmux.Tmux, townRoot string) { fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name) } else { witMgr := witness.NewManager(r) - if err := witMgr.Start(false); err != nil { + if err := witMgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name) } else { diff --git a/internal/cmd/up.go b/internal/cmd/up.go index 75293eed..9d7f15d9 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -118,7 +118,7 @@ func runUp(cmd *cobra.Command, args []string) error { } mgr := witness.NewManager(r) - if err := mgr.Start(false); err != nil { + if err := mgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName()) } else { diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index 84fdaef8..40c66051 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -15,8 +15,10 @@ import ( // Witness command flags var ( - witnessForeground bool - witnessStatusJSON bool + witnessForeground bool + witnessStatusJSON bool + witnessAgentOverride string + witnessEnvOverrides []string ) var witnessCmd = &cobra.Command{ @@ -41,6 +43,8 @@ states and takes action to keep work flowing. Examples: gt witness start greenplace + gt witness start greenplace --agent codex + gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku gt witness start greenplace --foreground`, Args: cobra.ExactArgs(1), RunE: runWitnessStart, @@ -93,7 +97,9 @@ var witnessRestartCmd = &cobra.Command{ Stops the current session (if running) and starts a fresh one. Examples: - gt witness restart greenplace`, + gt witness restart greenplace + gt witness restart greenplace --agent codex + gt witness restart greenplace --env ANTHROPIC_MODEL=claude-3-haiku`, Args: cobra.ExactArgs(1), RunE: runWitnessRestart, } @@ -101,10 +107,16 @@ Examples: func init() { // Start flags witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)") + witnessStartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)") + witnessStartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)") // Status flags witnessStatusCmd.Flags().BoolVar(&witnessStatusJSON, "json", false, "Output as JSON") + // Restart flags + witnessRestartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)") + witnessRestartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)") + // Add subcommands witnessCmd.AddCommand(witnessStartCmd) witnessCmd.AddCommand(witnessStopCmd) @@ -136,7 +148,7 @@ func runWitnessStart(cmd *cobra.Command, args []string) error { fmt.Printf("Starting witness for %s...\n", rigName) - if err := mgr.Start(witnessForeground); err != nil { + if err := mgr.Start(witnessForeground, witnessAgentOverride, witnessEnvOverrides); err != nil { if err == witness.ErrAlreadyRunning { fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠")) fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect")) @@ -289,7 +301,7 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error { sessionName := witnessSessionName(rigName) // Ensure session exists (creates if needed) - if err := mgr.Start(false); err != nil && err != witness.ErrAlreadyRunning { + if err := mgr.Start(false, "", nil); err != nil && err != witness.ErrAlreadyRunning { return err } else if err == nil { fmt.Printf("Started witness session for %s\n", rigName) @@ -322,7 +334,7 @@ func runWitnessRestart(cmd *cobra.Command, args []string) error { _ = mgr.Stop() // Start fresh - if err := mgr.Start(false); err != nil { + if err := mgr.Start(false, witnessAgentOverride, witnessEnvOverrides); err != nil { return fmt.Errorf("starting witness: %w", err) } diff --git a/internal/cmd/witness_test.go b/internal/cmd/witness_test.go new file mode 100644 index 00000000..cb5dec1c --- /dev/null +++ b/internal/cmd/witness_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestWitnessRestartAgentFlag(t *testing.T) { + flag := witnessRestartCmd.Flags().Lookup("agent") + if flag == nil { + t.Fatal("expected witness restart to define --agent flag") + } + if flag.DefValue != "" { + t.Errorf("expected default agent override to be empty, got %q", flag.DefValue) + } + if !strings.Contains(flag.Usage, "overrides town default") { + t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage) + } +} + +func TestWitnessStartAgentFlag(t *testing.T) { + flag := witnessStartCmd.Flags().Lookup("agent") + if flag == nil { + t.Fatal("expected witness start to define --agent flag") + } + if flag.DefValue != "" { + t.Errorf("expected default agent override to be empty, got %q", flag.DefValue) + } + if !strings.Contains(flag.Usage, "overrides town default") { + t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index cf1107b4..b56aecda 100755 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -396,7 +396,7 @@ func (d *Daemon) ensureWitnessRunning(rigName string) { } // Manager.Start() handles: zombie detection, session creation, env vars, theming, - // WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP). + // startup readiness waits, and crucially - startup/propulsion nudges (GUPP). // It returns ErrAlreadyRunning if Claude is already running in tmux. r := &rig.Rig{ Name: rigName, @@ -404,7 +404,7 @@ func (d *Daemon) ensureWitnessRunning(rigName string) { } mgr := witness.NewManager(r) - if err := mgr.Start(false); err != nil { + if err := mgr.Start(false, "", nil); err != nil { if err == witness.ErrAlreadyRunning { // Already running - nothing to do return diff --git a/internal/shell/integration.go b/internal/shell/integration.go index 6b16f054..f347cd5d 100644 --- a/internal/shell/integration.go +++ b/internal/shell/integration.go @@ -46,7 +46,9 @@ func Remove() error { } hookPath := filepath.Join(state.ConfigDir(), "shell-hook.sh") - os.Remove(hookPath) + if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing hook script: %w", err) + } return nil } @@ -97,7 +99,9 @@ func addToRCFile(path string) error { if len(data) > 0 { backupPath := path + ".gastown-backup" - os.WriteFile(backupPath, data, 0644) + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("writing backup: %w", err) + } } return os.WriteFile(path, []byte(content+block), 0644) diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 91acd055..e85c7ae4 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/steveyegge/gastown/internal/agent" @@ -16,6 +17,7 @@ import ( "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/util" + "github.com/steveyegge/gastown/internal/workspace" ) // Common errors @@ -99,7 +101,9 @@ func (m *Manager) witnessDir() string { // Start starts the witness. // If foreground is true, only updates state (no tmux session - deprecated). // Otherwise, spawns a Claude agent in a tmux session. -func (m *Manager) Start(foreground bool) error { +// agentOverride optionally specifies a different agent alias to use. +// envOverrides are KEY=VALUE pairs that override all other env var sources. +func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []string) error { w, err := m.loadState() if err != nil { return err @@ -152,21 +156,25 @@ func (m *Manager) Start(foreground bool) error { return fmt.Errorf("ensuring Claude settings: %w", err) } - // Build startup command first - // Pass m.rig.Path so rig agent settings are honored (not town-level defaults) - bdActor := fmt.Sprintf("%s/witness", m.rig.Name) - command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "") - runtimeConfig := config.LoadRuntimeConfig(m.rig.Path) - - // Create session with command directly to avoid send-keys race condition. - // See: https://github.com/anthropics/gastown/issues/280 - if err := t.NewSessionWithCommand(sessionID, witnessDir, command); err != nil { + // Create new tmux session + if err := t.NewSession(sessionID, witnessDir); err != nil { return fmt.Errorf("creating tmux session: %w", err) } + // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) + theme := tmux.AssignTheme(m.rig.Name) + _ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness") + + roleConfig, err := m.roleConfig() + if err != nil { + _ = t.KillSession(sessionID) + return err + } + + townRoot := m.townRoot() + // Set environment variables (non-fatal: session works without these) // Use centralized AgentEnv for consistency across all role startup paths - townRoot := filepath.Dir(m.rig.Path) envVars := config.AgentEnv(config.AgentEnvConfig{ Role: "witness", Rig: m.rig.Name, @@ -176,10 +184,16 @@ func (m *Manager) Start(foreground bool) error { for k, v := range envVars { _ = t.SetEnvironment(sessionID, k, v) } - - // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) - theme := tmux.AssignTheme(m.rig.Name) - _ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness") + // Apply role config env vars if present (non-fatal). + for key, value := range roleConfigEnvVars(roleConfig, townRoot, m.rig.Name) { + _ = t.SetEnvironment(sessionID, key, value) + } + // Apply CLI env overrides (highest priority, non-fatal). + for _, override := range envOverrides { + if key, value, ok := strings.Cut(override, "="); ok { + _ = t.SetEnvironment(sessionID, key, value) + } + } // Update state to running now := time.Now() @@ -192,8 +206,28 @@ func (m *Manager) Start(foreground bool) error { return fmt.Errorf("saving state: %w", err) } - // Wait for runtime to start and show its prompt (non-fatal) - if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil { + // Launch Claude directly (no shell respawn loop) + // Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan + // NOTE: No gt prime injection needed - SessionStart hook handles it automatically + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + // Pass m.rig.Path so rig agent settings are honored (not town-level defaults) + command, err := buildWitnessStartCommand(m.rig.Path, m.rig.Name, townRoot, agentOverride, roleConfig) + if err != nil { + _ = t.KillSession(sessionID) + return err + } + // Wait for shell to be ready before sending keys (prevents "can't find pane" under load) + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + _ = t.KillSession(sessionID) + return fmt.Errorf("waiting for shell: %w", err) + } + if err := t.SendKeys(sessionID, command); err != nil { + _ = t.KillSession(sessionID) // best-effort cleanup + return fmt.Errorf("starting Claude agent: %w", err) + } + + // Wait for Claude to start (non-fatal). + if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { // Non-fatal - try to continue anyway } @@ -219,6 +253,51 @@ func (m *Manager) Start(foreground bool) error { return nil } +func (m *Manager) roleConfig() (*beads.RoleConfig, error) { + beadsPath := m.rig.BeadsPath() + beadsDir := beads.ResolveBeadsDir(beadsPath) + bd := beads.NewWithBeadsDir(beadsPath, beadsDir) + roleConfig, err := bd.GetRoleConfig(beads.RoleBeadIDTown("witness")) + if err != nil { + return nil, fmt.Errorf("loading witness role config: %w", err) + } + return roleConfig, nil +} + +func (m *Manager) townRoot() string { + townRoot, err := workspace.Find(m.rig.Path) + if err != nil || townRoot == "" { + return m.rig.Path + } + return townRoot +} + +func roleConfigEnvVars(roleConfig *beads.RoleConfig, townRoot, rigName string) map[string]string { + if roleConfig == nil || len(roleConfig.EnvVars) == 0 { + return nil + } + expanded := make(map[string]string, len(roleConfig.EnvVars)) + for key, value := range roleConfig.EnvVars { + expanded[key] = beads.ExpandRolePattern(value, townRoot, rigName, "", "witness") + } + return expanded +} + +func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string, roleConfig *beads.RoleConfig) (string, error) { + if agentOverride != "" { + roleConfig = nil + } + if roleConfig != nil && roleConfig.StartCommand != "" { + return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil + } + bdActor := fmt.Sprintf("%s/witness", rigName) + command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", bdActor, rigPath, "", agentOverride) + if err != nil { + return "", fmt.Errorf("building startup command: %w", err) + } + return command, nil +} + // Stop stops the witness. func (m *Manager) Stop() error { w, err := m.loadState() diff --git a/internal/witness/manager_test.go b/internal/witness/manager_test.go new file mode 100644 index 00000000..1b88f909 --- /dev/null +++ b/internal/witness/manager_test.go @@ -0,0 +1,55 @@ +package witness + +import ( + "strings" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +func TestBuildWitnessStartCommand_UsesRoleConfig(t *testing.T) { + roleConfig := &beads.RoleConfig{ + StartCommand: "exec run --town {town} --rig {rig} --role {role}", + } + + got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "", roleConfig) + if err != nil { + t.Fatalf("buildWitnessStartCommand: %v", err) + } + + want := "exec run --town /town --rig gastown --role witness" + if got != want { + t.Errorf("buildWitnessStartCommand = %q, want %q", got, want) + } +} + +func TestBuildWitnessStartCommand_DefaultsToRuntime(t *testing.T) { + got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "", nil) + if err != nil { + t.Fatalf("buildWitnessStartCommand: %v", err) + } + + if !strings.Contains(got, "GT_ROLE=witness") { + t.Errorf("expected GT_ROLE=witness in command, got %q", got) + } + if !strings.Contains(got, "BD_ACTOR=gastown/witness") { + t.Errorf("expected BD_ACTOR=gastown/witness in command, got %q", got) + } +} + +func TestBuildWitnessStartCommand_AgentOverrideWins(t *testing.T) { + roleConfig := &beads.RoleConfig{ + StartCommand: "exec run --role {role}", + } + + got, err := buildWitnessStartCommand("/town/rig", "gastown", "/town", "codex", roleConfig) + if err != nil { + t.Fatalf("buildWitnessStartCommand: %v", err) + } + if strings.Contains(got, "exec run") { + t.Fatalf("expected agent override to bypass role start_command, got %q", got) + } + if !strings.Contains(got, "GT_ROLE=witness") { + t.Errorf("expected GT_ROLE=witness in command, got %q", got) + } +} diff --git a/internal/wrappers/wrappers.go b/internal/wrappers/wrappers.go index 0b5a2212..a0c92e62 100644 --- a/internal/wrappers/wrappers.go +++ b/internal/wrappers/wrappers.go @@ -48,7 +48,9 @@ func Remove() error { wrappers := []string{"gt-codex", "gt-opencode"} for _, name := range wrappers { destPath := filepath.Join(binDir, name) - os.Remove(destPath) + if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", name, err) + } } return nil