From 9afd6c557270a86f72e8c0ddf01bf7dc8819b6d4 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 13:26:38 -0800 Subject: [PATCH] feat: Set BD_ACTOR env var when spawning agents (gt-rhfji) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When gt spawns agents (polecats, crew, patrol roles), it now sets the BD_ACTOR env var so that bd commands (like `bd hook`) know the agent identity without coupling to gt. Updated spawn points: - gt up (mayor, deacon, witness via ensureSession/ensureWitness) - gt deacon start - gt witness start - gt start refinery - gt mayor start - Daemon deacon restart - Daemon lifecycle restart - Handoff respawn - Refinery manager start BD_ACTOR uses slash format (e.g., gastown/witness, gastown/crew/max) while GT_ROLE may use dash format internally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/deacon.go | 5 +++-- internal/cmd/handoff.go | 6 +++--- internal/cmd/mayor.go | 5 +++-- internal/cmd/start.go | 6 ++++-- internal/cmd/up.go | 13 ++++++++----- internal/cmd/witness.go | 6 ++++-- internal/daemon/daemon.go | 7 ++++--- internal/daemon/lifecycle.go | 36 ++++++++++++++++++++++++++++++++++++ internal/refinery/manager.go | 2 ++ 9 files changed, 67 insertions(+), 19 deletions(-) diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 64a9e213..dda0133b 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -202,6 +202,7 @@ func startDeaconSession(t *tmux.Tmux) error { // Set environment _ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon") + _ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon") // Apply Deacon theme theme := tmux.DeaconTheme() @@ -210,8 +211,8 @@ func startDeaconSession(t *tmux.Tmux) error { // Launch Claude directly (no shell respawn loop) // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat // The startup hook handles context loading automatically - // Export GT_ROLE in the command since tmux SetEnvironment only affects new panes - if err := t.SendKeys(DeaconSessionName, "export GT_ROLE=deacon && claude --dangerously-skip-permissions"); err != nil { + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + if err := t.SendKeys(DeaconSessionName, "export GT_ROLE=deacon BD_ACTOR=deacon && claude --dangerously-skip-permissions"); err != nil { return fmt.Errorf("sending command: %w", err) } diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 7609dc82..ce11d192 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -268,17 +268,17 @@ func buildRestartCommand(sessionName string) (string, error) { return "", err } - // Determine GT_ROLE value for this session + // Determine GT_ROLE and BD_ACTOR values for this session gtRole := sessionToGTRole(sessionName) // For respawn-pane, we: // 1. cd to the right directory (role's canonical home) - // 2. export GT_ROLE so role detection works correctly + // 2. export GT_ROLE and BD_ACTOR so role detection works correctly // 3. run claude // The SessionStart hook will run gt prime. // Use exec to ensure clean process replacement. if gtRole != "" { - return fmt.Sprintf("cd %s && export GT_ROLE=%s && exec claude --dangerously-skip-permissions", workDir, gtRole), nil + return fmt.Sprintf("cd %s && export GT_ROLE=%s BD_ACTOR=%s && exec claude --dangerously-skip-permissions", workDir, gtRole, gtRole), nil } return fmt.Sprintf("cd %s && exec claude --dangerously-skip-permissions", workDir), nil } diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 5b45c863..1aa6d563 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -120,6 +120,7 @@ func startMayorSession(t *tmux.Tmux) error { // Set environment _ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor") + _ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor") // Apply Mayor theme theme := tmux.MayorTheme() @@ -127,8 +128,8 @@ func startMayorSession(t *tmux.Tmux) error { // Launch Claude - the startup hook handles 'gt prime' automatically // Use SendKeysDelayed to allow shell initialization after NewSession - // Export GT_ROLE in the command since tmux SetEnvironment only affects new panes - claudeCmd := `export GT_ROLE=mayor && claude --dangerously-skip-permissions` + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + claudeCmd := `export GT_ROLE=mayor BD_ACTOR=mayor && claude --dangerously-skip-permissions` if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil { return fmt.Errorf("sending command: %w", err) } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 12e91bde..fb6c0473 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -271,8 +271,10 @@ func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) { } // Set environment + bdActor := fmt.Sprintf("%s/refinery", rigName) t.SetEnvironment(sessionName, "GT_ROLE", "refinery") t.SetEnvironment(sessionName, "GT_RIG", rigName) + t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) // Set beads environment beadsDir := filepath.Join(r.Path, "mayor", "rig", ".beads") @@ -285,8 +287,8 @@ func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) { _ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery") // Launch Claude in a respawn loop - // Export GT_ROLE in the command since tmux SetEnvironment only affects new panes - loopCmd := `export GT_ROLE=refinery && while true; do echo "🛢️ Starting Refinery for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Refinery exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + loopCmd := `export GT_ROLE=refinery BD_ACTOR=` + bdActor + ` && while true; do echo "🛢️ Starting Refinery for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Refinery exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil { return false, fmt.Errorf("sending command: %w", err) } diff --git a/internal/cmd/up.go b/internal/cmd/up.go index c6700faf..79ee00f9 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -202,6 +202,7 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { // Set environment _ = t.SetEnvironment(sessionName, "GT_ROLE", role) + _ = t.SetEnvironment(sessionName, "BD_ACTOR", role) // Apply theme based on role switch role { @@ -214,13 +215,13 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { } // Launch Claude - // Export GT_ROLE in the command since tmux SetEnvironment only affects new panes + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes var claudeCmd string if role == "deacon" { // Deacon uses respawn loop - claudeCmd = `export GT_ROLE=deacon && while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` + claudeCmd = `export GT_ROLE=deacon BD_ACTOR=deacon && while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` } else { - claudeCmd = fmt.Sprintf(`export GT_ROLE=%s && claude --dangerously-skip-permissions`, role) + claudeCmd = fmt.Sprintf(`export GT_ROLE=%s BD_ACTOR=%s && claude --dangerously-skip-permissions`, role, role) } if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { @@ -246,16 +247,18 @@ func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error { } // Set environment + bdActor := fmt.Sprintf("%s/witness", rigName) _ = t.SetEnvironment(sessionName, "GT_ROLE", "witness") _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) + _ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) // Apply theme (use rig-based theme) theme := tmux.AssignTheme(rigName) _ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName) // Launch Claude - // Export GT_ROLE in the command since tmux SetEnvironment only affects new panes - claudeCmd := `export GT_ROLE=witness && claude --dangerously-skip-permissions` + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + claudeCmd := fmt.Sprintf(`export GT_ROLE=witness BD_ACTOR=%s && claude --dangerously-skip-permissions`, bdActor) if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { return err } diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index 2018d221..8090a53e 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -321,8 +321,10 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { } // Set environment + bdActor := fmt.Sprintf("%s/witness", rigName) t.SetEnvironment(sessionName, "GT_ROLE", "witness") t.SetEnvironment(sessionName, "GT_RIG", rigName) + t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) // Apply Gas Town theming theme := tmux.AssignTheme(rigName) @@ -331,8 +333,8 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { // 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 in the command since tmux SetEnvironment only affects new panes - if err := t.SendKeys(sessionName, "export GT_ROLE=witness && claude --dangerously-skip-permissions"); err != nil { + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + if err := t.SendKeys(sessionName, fmt.Sprintf("export GT_ROLE=witness BD_ACTOR=%s && claude --dangerously-skip-permissions", bdActor)); err != nil { return false, fmt.Errorf("sending command: %w", err) } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 68ed1920..3de04256 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -197,7 +197,7 @@ func (d *Daemon) ensureDeaconRunning() { // Claude has exited (shell is showing) - restart it d.logger.Printf("Deacon session exists but Claude exited (cmd=%s), restarting...", cmd) - if err := d.tmux.SendKeys(DeaconSessionName, "export GT_ROLE=deacon && claude --dangerously-skip-permissions"); err != nil { + if err := d.tmux.SendKeys(DeaconSessionName, "export GT_ROLE=deacon BD_ACTOR=deacon && claude --dangerously-skip-permissions"); err != nil { d.logger.Printf("Error restarting Claude in Deacon session: %v", err) } return @@ -215,11 +215,12 @@ func (d *Daemon) ensureDeaconRunning() { // Set environment _ = d.tmux.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon") + _ = d.tmux.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon") // Launch Claude directly (no shell respawn loop) // The daemon will detect if Claude exits and restart it on next heartbeat - // Export GT_ROLE so Claude inherits it (tmux SetEnvironment doesn't export to processes) - if err := d.tmux.SendKeys(DeaconSessionName, "export GT_ROLE=deacon && claude --dangerously-skip-permissions"); err != nil { + // Export GT_ROLE and BD_ACTOR so Claude inherits them (tmux SetEnvironment doesn't export to processes) + if err := d.tmux.SendKeys(DeaconSessionName, "export GT_ROLE=deacon BD_ACTOR=deacon && claude --dangerously-skip-permissions"); err != nil { d.logger.Printf("Error launching Claude in Deacon session: %v", err) return } diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index a40f40b2..d5750e1b 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -262,6 +262,9 @@ func (d *Daemon) restartSession(sessionName, identity string) error { // Set environment _ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity) + // BD_ACTOR uses slashes instead of dashes for path-like identity + bdActor := identityToBDActor(identity) + _ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) // Apply theme if identity == "mayor" { @@ -427,3 +430,36 @@ func (d *Daemon) identityToStateFile(identity string) string { return "" } } + +// identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes). +// Examples: +// - "mayor" → "mayor" +// - "gastown-witness" → "gastown/witness" +// - "gastown-refinery" → "gastown/refinery" +// - "gastown-crew-max" → "gastown/crew/max" +func identityToBDActor(identity string) string { + switch identity { + case "mayor", "deacon": + return identity + default: + // Pattern: -witness → /witness + if strings.HasSuffix(identity, "-witness") { + rigName := strings.TrimSuffix(identity, "-witness") + return rigName + "/witness" + } + // Pattern: -refinery → /refinery + if strings.HasSuffix(identity, "-refinery") { + rigName := strings.TrimSuffix(identity, "-refinery") + return rigName + "/refinery" + } + // Pattern: -crew-/crew/ + if strings.Contains(identity, "-crew-") { + parts := strings.SplitN(identity, "-crew-", 2) + if len(parts) == 2 { + return parts[0] + "/crew/" + parts[1] + } + } + // Unknown format - return as-is + return identity + } +} diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index b24bddc1..8fa863f3 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -200,9 +200,11 @@ func (m *Manager) Start(foreground bool) error { } // Set environment variables + bdActor := fmt.Sprintf("%s/refinery", m.rig.Name) _ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name) _ = t.SetEnvironment(sessionID, "GT_REFINERY", "1") _ = t.SetEnvironment(sessionID, "GT_ROLE", "refinery") + _ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor) // Set beads environment - refinery uses rig-level beads beadsDir := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")