Recovered from reflog - these commits were lost during a rebase/force-push. Dogs are directories with state files but no sessions. When `gt dog dispatch` assigned work and sent mail, nothing executed because no session existed. Changes: 1. Spawn tmux session after dispatch (gt-<town>-deacon-<dogname>) 2. Set BD_ACTOR=deacon/dogs/<name> so dogs can find their mail 3. Add dog case to AgentEnv for proper identity Session spawn is non-blocking - if it fails, mail was sent and human can manually start the session. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
252 lines
7.3 KiB
Go
252 lines
7.3 KiB
Go
// Package config provides configuration loading and environment variable management.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// AgentEnvConfig specifies the configuration for generating agent environment variables.
|
|
// This is the single source of truth for all agent environment configuration.
|
|
type AgentEnvConfig struct {
|
|
// Role is the agent role: mayor, deacon, witness, refinery, crew, polecat, boot
|
|
Role string
|
|
|
|
// Rig is the rig name (empty for town-level agents like mayor/deacon)
|
|
Rig string
|
|
|
|
// AgentName is the specific agent name (empty for singletons like witness/refinery)
|
|
// For polecats, this is the polecat name. For crew, this is the crew member name.
|
|
AgentName string
|
|
|
|
// TownRoot is the root of the Gas Town workspace.
|
|
// Sets GT_ROOT environment variable.
|
|
TownRoot string
|
|
|
|
// RuntimeConfigDir is the optional CLAUDE_CONFIG_DIR path
|
|
RuntimeConfigDir string
|
|
|
|
// SessionIDEnv is the environment variable name that holds the session ID.
|
|
// Sets GT_SESSION_ID_ENV so the runtime knows where to find the session ID.
|
|
SessionIDEnv string
|
|
|
|
// BeadsNoDaemon sets BEADS_NO_DAEMON=1 if true
|
|
// Used for polecats that should bypass the beads daemon
|
|
BeadsNoDaemon bool
|
|
}
|
|
|
|
// AgentEnv returns all environment variables for an agent based on the config.
|
|
// This is the single source of truth for agent environment variables.
|
|
func AgentEnv(cfg AgentEnvConfig) map[string]string {
|
|
env := make(map[string]string)
|
|
|
|
env["GT_ROLE"] = cfg.Role
|
|
|
|
// Set role-specific variables
|
|
switch cfg.Role {
|
|
case "mayor":
|
|
env["BD_ACTOR"] = "mayor"
|
|
env["GIT_AUTHOR_NAME"] = "mayor"
|
|
env["GIT_AUTHOR_EMAIL"] = "mayor@gastown.local"
|
|
|
|
case "deacon":
|
|
env["BD_ACTOR"] = "deacon"
|
|
env["GIT_AUTHOR_NAME"] = "deacon"
|
|
env["GIT_AUTHOR_EMAIL"] = "deacon@gastown.local"
|
|
|
|
case "boot":
|
|
env["BD_ACTOR"] = "deacon-boot"
|
|
env["GIT_AUTHOR_NAME"] = "boot"
|
|
env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local"
|
|
|
|
case "dog":
|
|
env["BD_ACTOR"] = fmt.Sprintf("deacon/dogs/%s", cfg.AgentName)
|
|
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("dog-%s", cfg.AgentName)
|
|
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("dog-%s@gastown.local", cfg.AgentName)
|
|
|
|
case "witness":
|
|
env["GT_RIG"] = cfg.Rig
|
|
env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig)
|
|
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig)
|
|
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-witness@gastown.local", cfg.Rig)
|
|
|
|
case "refinery":
|
|
env["GT_RIG"] = cfg.Rig
|
|
env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig)
|
|
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig)
|
|
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-refinery@gastown.local", cfg.Rig)
|
|
|
|
case "polecat":
|
|
env["GT_RIG"] = cfg.Rig
|
|
env["GT_POLECAT"] = cfg.AgentName
|
|
env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName)
|
|
env["GIT_AUTHOR_NAME"] = cfg.AgentName
|
|
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-polecat-%s@gastown.local", cfg.Rig, cfg.AgentName)
|
|
|
|
case "crew":
|
|
env["GT_RIG"] = cfg.Rig
|
|
env["GT_CREW"] = cfg.AgentName
|
|
env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName)
|
|
env["GIT_AUTHOR_NAME"] = cfg.AgentName
|
|
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-crew-%s@gastown.local", cfg.Rig, cfg.AgentName)
|
|
}
|
|
|
|
// Only set GT_ROOT if provided
|
|
// Empty values would override tmux session environment
|
|
if cfg.TownRoot != "" {
|
|
env["GT_ROOT"] = cfg.TownRoot
|
|
}
|
|
|
|
// Set BEADS_AGENT_NAME for polecat/crew (uses same format as BD_ACTOR)
|
|
if cfg.Role == "polecat" || cfg.Role == "crew" {
|
|
env["BEADS_AGENT_NAME"] = fmt.Sprintf("%s/%s", cfg.Rig, cfg.AgentName)
|
|
}
|
|
|
|
if cfg.BeadsNoDaemon {
|
|
env["BEADS_NO_DAEMON"] = "1"
|
|
}
|
|
|
|
// Add optional runtime config directory
|
|
if cfg.RuntimeConfigDir != "" {
|
|
env["CLAUDE_CONFIG_DIR"] = cfg.RuntimeConfigDir
|
|
}
|
|
|
|
// Add session ID env var name if provided
|
|
if cfg.SessionIDEnv != "" {
|
|
env["GT_SESSION_ID_ENV"] = cfg.SessionIDEnv
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
// AgentEnvSimple is a convenience function for simple role-based env var lookup.
|
|
// Use this when you only need role, rig, and agentName without advanced options.
|
|
func AgentEnvSimple(role, rig, agentName string) map[string]string {
|
|
return AgentEnv(AgentEnvConfig{
|
|
Role: role,
|
|
Rig: rig,
|
|
AgentName: agentName,
|
|
})
|
|
}
|
|
|
|
// ShellQuote returns a shell-safe quoted string.
|
|
// Values containing special characters are wrapped in single quotes.
|
|
// Single quotes within the value are escaped using the '\” idiom.
|
|
func ShellQuote(s string) string {
|
|
// Check if quoting is needed (contains shell special chars)
|
|
needsQuoting := false
|
|
for _, c := range s {
|
|
switch c {
|
|
case ' ', '\t', '\n', '"', '\'', '`', '$', '\\', '!', '*', '?',
|
|
'[', ']', '{', '}', '(', ')', '<', '>', '|', '&', ';', '#':
|
|
needsQuoting = true
|
|
}
|
|
if needsQuoting {
|
|
break
|
|
}
|
|
}
|
|
|
|
if !needsQuoting {
|
|
return s
|
|
}
|
|
|
|
// Use single quotes, escaping any embedded single quotes
|
|
// 'foo'\''bar' means: 'foo' + escaped-single-quote + 'bar'
|
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
|
}
|
|
|
|
// ExportPrefix builds an export statement prefix for shell commands.
|
|
// Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && "
|
|
// The keys are sorted for deterministic output.
|
|
// Values containing special characters are properly shell-quoted.
|
|
func ExportPrefix(env map[string]string) string {
|
|
if len(env) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Sort keys for deterministic output
|
|
keys := make([]string, 0, len(env))
|
|
for k := range env {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var parts []string
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%s", k, ShellQuote(env[k])))
|
|
}
|
|
|
|
return "export " + strings.Join(parts, " ") + " && "
|
|
}
|
|
|
|
// BuildStartupCommandWithEnv builds a startup command with the given environment variables.
|
|
// This combines the export prefix with the agent command and optional prompt.
|
|
func BuildStartupCommandWithEnv(env map[string]string, agentCmd, prompt string) string {
|
|
prefix := ExportPrefix(env)
|
|
|
|
if prompt != "" {
|
|
// Include prompt as argument to agent command
|
|
return fmt.Sprintf("%s%s %q", prefix, agentCmd, prompt)
|
|
}
|
|
return prefix + agentCmd
|
|
}
|
|
|
|
// MergeEnv merges multiple environment maps, with later maps taking precedence.
|
|
func MergeEnv(maps ...map[string]string) map[string]string {
|
|
result := make(map[string]string)
|
|
for _, m := range maps {
|
|
for k, v := range m {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FilterEnv returns a new map with only the specified keys.
|
|
func FilterEnv(env map[string]string, keys ...string) map[string]string {
|
|
result := make(map[string]string)
|
|
for _, k := range keys {
|
|
if v, ok := env[k]; ok {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// WithoutEnv returns a new map without the specified keys.
|
|
func WithoutEnv(env map[string]string, keys ...string) map[string]string {
|
|
result := make(map[string]string)
|
|
exclude := make(map[string]bool)
|
|
for _, k := range keys {
|
|
exclude[k] = true
|
|
}
|
|
for k, v := range env {
|
|
if !exclude[k] {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// EnvForExecCommand returns os.Environ() with the given env vars appended.
|
|
// This is useful for setting cmd.Env on exec.Command.
|
|
func EnvForExecCommand(env map[string]string) []string {
|
|
result := os.Environ()
|
|
for k, v := range env {
|
|
result = append(result, k+"="+v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// EnvToSlice converts an env map to a slice of "K=V" strings.
|
|
// Useful for appending to os.Environ() manually.
|
|
func EnvToSlice(env map[string]string) []string {
|
|
result := make([]string, 0, len(env))
|
|
for k, v := range env {
|
|
result = append(result, k+"="+v)
|
|
}
|
|
return result
|
|
}
|