Merge remote-tracking branch 'origin/main' into polecat/organic-mk4yjuw0
This commit is contained in:
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **New agent presets** - Added Cursor, Auggie (Augment Code), and Sourcegraph AMP as built-in agent presets
|
||||||
|
- **Centralized agent list** - Agent preset configuration moved to `config.go` for easier maintenance
|
||||||
|
|
||||||
## [0.2.2] - 2026-01-07
|
## [0.2.2] - 2026-01-07
|
||||||
|
|
||||||
Rig operational state management, unified agent startup, and extensive stability fixes.
|
Rig operational state management, unified agent startup, and extensive stability fixes.
|
||||||
|
|||||||
@@ -274,12 +274,14 @@ gt crew add <name> --rig <rig> # Create crew workspace
|
|||||||
```bash
|
```bash
|
||||||
gt agents # List active agents
|
gt agents # List active agents
|
||||||
gt sling <issue> <rig> # Assign work to agent
|
gt sling <issue> <rig> # Assign work to agent
|
||||||
gt sling <issue> <rig> --agent codex # Override runtime for this sling/spawn
|
gt sling <issue> <rig> --agent cursor # Override runtime for this sling/spawn
|
||||||
gt mayor attach # Start Mayor session
|
gt mayor attach # Start Mayor session
|
||||||
gt mayor start --agent gemini # Run Mayor with a specific agent alias
|
gt mayor start --agent auggie # Run Mayor with a specific agent alias
|
||||||
gt prime # Alternative to mayor attach
|
gt prime # Alternative to mayor attach
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Built-in agent presets**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||||
|
|
||||||
### Convoy (Work Tracking)
|
### Convoy (Work Tracking)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -359,7 +359,7 @@ gt config agent remove <name> # Remove custom agent (built-ins protected)
|
|||||||
gt config default-agent [name] # Get or set town default agent
|
gt config default-agent [name] # Get or set town default agent
|
||||||
```
|
```
|
||||||
|
|
||||||
**Built-in agents**: `claude`, `gemini`, `codex`
|
**Built-in agents**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||||
|
|
||||||
**Custom agents**: Define per-town in `mayor/town.json`:
|
**Custom agents**: Define per-town in `mayor/town.json`:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
|||||||
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
||||||
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
||||||
// This is safe to call even if the directory doesn't exist.
|
// This is safe to call even if the directory doesn't exist.
|
||||||
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
func cleanBeadsRuntimeFiles(beadsDir string) {
|
||||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
return nil // Nothing to clean
|
return // Nothing to clean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime files/patterns that are gitignored and safe to remove
|
// Runtime files/patterns that are gitignored and safe to remove
|
||||||
@@ -144,11 +144,9 @@ func cleanBeadsRuntimeFiles(beadsDir string) error {
|
|||||||
continue // Invalid pattern, skip
|
continue // Invalid pattern, skip
|
||||||
}
|
}
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
os.RemoveAll(match) // Best effort, ignore errors
|
_ = os.RemoveAll(match) // Best effort, ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||||
@@ -192,9 +190,7 @@ func SetupRedirect(townRoot, worktreePath string) error {
|
|||||||
|
|
||||||
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
||||||
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||||
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
cleanBeadsRuntimeFiles(worktreeBeadsDir)
|
||||||
return fmt.Errorf("cleaning runtime files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create .beads directory if it doesn't exist
|
// Create .beads directory if it doesn't exist
|
||||||
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func runConfigAgentList(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Collect all agents
|
// Collect all agents
|
||||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
builtInAgents := config.ListAgentPresets()
|
||||||
customAgents := make(map[string]*config.RuntimeConfig)
|
customAgents := make(map[string]*config.RuntimeConfig)
|
||||||
if townSettings.Agents != nil {
|
if townSettings.Agents != nil {
|
||||||
for name, runtime := range townSettings.Agents {
|
for name, runtime := range townSettings.Agents {
|
||||||
@@ -330,7 +330,7 @@ func runConfigAgentSet(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Agent '%s' set to: %s\n", style.Bold.Render(name), commandLine)
|
fmt.Printf("Agent '%s' set to: %s\n", style.Bold.Render(name), commandLine)
|
||||||
|
|
||||||
// Check if this overrides a built-in
|
// Check if this overrides a built-in
|
||||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
builtInAgents := config.ListAgentPresets()
|
||||||
for _, builtin := range builtInAgents {
|
for _, builtin := range builtInAgents {
|
||||||
if name == builtin {
|
if name == builtin {
|
||||||
fmt.Printf("\n%s\n", style.Dim.Render("(overriding built-in '"+builtin+"' preset)"))
|
fmt.Printf("\n%s\n", style.Dim.Render("(overriding built-in '"+builtin+"' preset)"))
|
||||||
@@ -350,7 +350,7 @@ func runConfigAgentRemove(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if trying to remove built-in
|
// Check if trying to remove built-in
|
||||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
builtInAgents := config.ListAgentPresets()
|
||||||
for _, builtin := range builtInAgents {
|
for _, builtin := range builtInAgents {
|
||||||
if name == builtin {
|
if name == builtin {
|
||||||
return fmt.Errorf("cannot remove built-in agent '%s' (use 'gt config agent set' to override it)", name)
|
return fmt.Errorf("cannot remove built-in agent '%s' (use 'gt config agent set' to override it)", name)
|
||||||
@@ -415,7 +415,7 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Verify agent exists
|
// Verify agent exists
|
||||||
isValid := false
|
isValid := false
|
||||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
builtInAgents := config.ListAgentPresets()
|
||||||
for _, builtin := range builtInAgents {
|
for _, builtin := range builtInAgents {
|
||||||
if name == builtin {
|
if name == builtin {
|
||||||
isValid = true
|
isValid = true
|
||||||
|
|||||||
@@ -649,10 +649,14 @@ func deriveSessionName() string {
|
|||||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Town-level roles (mayor, deacon): gt-{town}-{role}
|
// Town-level roles (mayor, deacon): gt-{town}-{role} or gt-{role}
|
||||||
if (role == "mayor" || role == "deacon") && town != "" {
|
if role == "mayor" || role == "deacon" {
|
||||||
|
if town != "" {
|
||||||
return fmt.Sprintf("gt-%s-%s", town, role)
|
return fmt.Sprintf("gt-%s-%s", town, role)
|
||||||
}
|
}
|
||||||
|
// No town set - use simple gt-{role} pattern
|
||||||
|
return fmt.Sprintf("gt-%s", role)
|
||||||
|
}
|
||||||
|
|
||||||
// Rig-based roles (witness, refinery): gt-{rig}-{role}
|
// Rig-based roles (witness, refinery): gt-{rig}-{role}
|
||||||
if role != "" && rig != "" {
|
if role != "" && rig != "" {
|
||||||
@@ -664,12 +668,9 @@ func deriveSessionName() string {
|
|||||||
|
|
||||||
// detectCurrentTmuxSession returns the current tmux session name if running inside tmux.
|
// detectCurrentTmuxSession returns the current tmux session name if running inside tmux.
|
||||||
// Uses `tmux display-message -p '#S'` which prints the session name.
|
// Uses `tmux display-message -p '#S'` which prints the session name.
|
||||||
|
// Note: We don't check TMUX env var because it may not be inherited when Claude Code
|
||||||
|
// runs bash commands, even though we are inside a tmux session.
|
||||||
func detectCurrentTmuxSession() string {
|
func detectCurrentTmuxSession() string {
|
||||||
// Check if we're inside tmux
|
|
||||||
if os.Getenv("TMUX") == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("tmux", "display-message", "-p", "#S")
|
cmd := exec.Command("tmux", "display-message", "-p", "#S")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ func TestDeriveSessionName(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "gt-ai-deacon",
|
expected: "gt-ai-deacon",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mayor session without GT_TOWN",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"GT_ROLE": "mayor",
|
||||||
|
},
|
||||||
|
expected: "gt-mayor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deacon session without GT_TOWN",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"GT_ROLE": "deacon",
|
||||||
|
},
|
||||||
|
expected: "gt-deacon",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "no env vars",
|
name: "no env vars",
|
||||||
envVars: map[string]string{},
|
envVars: map[string]string{},
|
||||||
|
|||||||
+13
-1
@@ -29,7 +29,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
// Try to detect from current directory
|
// Try to detect from current directory
|
||||||
detected, err := detectCrewFromCwd()
|
detected, err := detectCrewFromCwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at <name>", err)
|
// Try to show available crew members if we can detect the rig
|
||||||
|
hint := "\n\nUsage: gt crew at <name>"
|
||||||
|
if crewRig != "" {
|
||||||
|
if mgr, _, mgrErr := getCrewManager(crewRig); mgrErr == nil {
|
||||||
|
if members, listErr := mgr.List(); listErr == nil && len(members) > 0 {
|
||||||
|
hint = fmt.Sprintf("\n\nAvailable crew in %s:", crewRig)
|
||||||
|
for _, m := range members {
|
||||||
|
hint += fmt.Sprintf("\n %s", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("could not detect crew workspace from current directory: %w%s", err, hint)
|
||||||
}
|
}
|
||||||
name = detected.crewName
|
name = detected.crewName
|
||||||
if crewRig == "" {
|
if crewRig == "" {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
|
|||||||
// Look for pattern: <rig>/crew/<name>/...
|
// Look for pattern: <rig>/crew/<name>/...
|
||||||
// Minimum: rig, crew, name = 3 parts
|
// Minimum: rig, crew, name = 3 parts
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
return nil, fmt.Errorf("not in a crew workspace (path too short)")
|
return nil, fmt.Errorf("not inside a crew workspace - specify the crew name or cd into a crew directory (e.g., gastown/crew/max)")
|
||||||
}
|
}
|
||||||
|
|
||||||
rigName := parts[0]
|
rigName := parts[0]
|
||||||
|
|||||||
@@ -313,6 +313,16 @@ func initTownBeads(townPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure custom types for Gas Town (agent, role, rig, convoy).
|
||||||
|
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
||||||
|
customTypes := "agent,role,rig,convoy,event"
|
||||||
|
configCmd := exec.Command("bd", "config", "set", "types.custom", customTypes)
|
||||||
|
configCmd.Dir = townPath
|
||||||
|
if configOutput, configErr := configCmd.CombinedOutput(); configErr != nil {
|
||||||
|
// Non-fatal: older beads versions don't need this, newer ones do
|
||||||
|
fmt.Printf(" %s Could not set custom types: %s\n", style.Dim.Render("⚠"), strings.TrimSpace(string(configOutput)))
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure database has repository fingerprint (GH #25).
|
// Ensure database has repository fingerprint (GH #25).
|
||||||
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
||||||
// Without fingerprint, the bd daemon fails to start silently.
|
// Without fingerprint, the bd daemon fails to start silently.
|
||||||
@@ -321,6 +331,14 @@ func initTownBeads(townPath string) error {
|
|||||||
fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err)
|
fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register Gas Town custom types (agent, role, rig, convoy, slot).
|
||||||
|
// These types are not built into beads core - they must be registered
|
||||||
|
// before creating agent/role beads. See GH #gt-xyz for context.
|
||||||
|
if err := ensureCustomTypes(townPath); err != nil {
|
||||||
|
// Non-fatal but will cause agent bead creation to fail
|
||||||
|
fmt.Printf(" %s Could not register custom types: %v\n", style.Dim.Render("⚠"), err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +355,20 @@ func ensureRepoFingerprint(beadsPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureCustomTypes registers Gas Town custom issue types with beads.
|
||||||
|
// Beads core only supports built-in types (bug, feature, task, etc.).
|
||||||
|
// Gas Town needs custom types: agent, role, rig, convoy, slot.
|
||||||
|
// This is idempotent - safe to call multiple times.
|
||||||
|
func ensureCustomTypes(beadsPath string) error {
|
||||||
|
cmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot")
|
||||||
|
cmd.Dir = beadsPath
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bd config set types.custom: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// initTownAgentBeads creates town-level agent and role beads using hq- prefix.
|
// initTownAgentBeads creates town-level agent and role beads using hq- prefix.
|
||||||
// This creates:
|
// This creates:
|
||||||
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ const (
|
|||||||
AgentGemini AgentPreset = "gemini"
|
AgentGemini AgentPreset = "gemini"
|
||||||
// AgentCodex is OpenAI Codex.
|
// AgentCodex is OpenAI Codex.
|
||||||
AgentCodex AgentPreset = "codex"
|
AgentCodex AgentPreset = "codex"
|
||||||
|
// AgentCursor is Cursor Agent.
|
||||||
|
AgentCursor AgentPreset = "cursor"
|
||||||
|
// AgentAuggie is Auggie CLI.
|
||||||
|
AgentAuggie AgentPreset = "auggie"
|
||||||
|
// AgentAmp is Sourcegraph AMP.
|
||||||
|
AgentAmp AgentPreset = "amp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentPresetInfo contains the configuration details for an agent preset.
|
// AgentPresetInfo contains the configuration details for an agent preset.
|
||||||
// This extends the basic RuntimeConfig with agent-specific metadata.
|
// This extends the basic RuntimeConfig with agent-specific metadata.
|
||||||
type AgentPresetInfo struct {
|
type AgentPresetInfo struct {
|
||||||
// Name is the preset identifier (e.g., "claude", "gemini", "codex").
|
// Name is the preset identifier (e.g., "claude", "gemini", "codex", "cursor", "auggie", "amp").
|
||||||
Name AgentPreset `json:"name"`
|
Name AgentPreset `json:"name"`
|
||||||
|
|
||||||
// Command is the CLI binary to invoke.
|
// Command is the CLI binary to invoke.
|
||||||
@@ -35,6 +41,11 @@ type AgentPresetInfo struct {
|
|||||||
// Args are the default command-line arguments for autonomous mode.
|
// Args are the default command-line arguments for autonomous mode.
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
|
|
||||||
|
// ProcessNames are the process names to look for when detecting if the agent is running.
|
||||||
|
// Used by tmux.IsAgentRunning to check pane_current_command.
|
||||||
|
// E.g., ["node"] for Claude, ["cursor-agent"] for Cursor.
|
||||||
|
ProcessNames []string `json:"process_names,omitempty"`
|
||||||
|
|
||||||
// SessionIDEnv is the environment variable for session ID.
|
// SessionIDEnv is the environment variable for session ID.
|
||||||
// Used for resuming sessions across restarts.
|
// Used for resuming sessions across restarts.
|
||||||
SessionIDEnv string `json:"session_id_env,omitempty"`
|
SessionIDEnv string `json:"session_id_env,omitempty"`
|
||||||
@@ -91,6 +102,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
|||||||
Name: AgentClaude,
|
Name: AgentClaude,
|
||||||
Command: "claude",
|
Command: "claude",
|
||||||
Args: []string{"--dangerously-skip-permissions"},
|
Args: []string{"--dangerously-skip-permissions"},
|
||||||
|
ProcessNames: []string{"node"}, // Claude runs as Node.js
|
||||||
SessionIDEnv: "CLAUDE_SESSION_ID",
|
SessionIDEnv: "CLAUDE_SESSION_ID",
|
||||||
ResumeFlag: "--resume",
|
ResumeFlag: "--resume",
|
||||||
ResumeStyle: "flag",
|
ResumeStyle: "flag",
|
||||||
@@ -102,6 +114,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
|||||||
Name: AgentGemini,
|
Name: AgentGemini,
|
||||||
Command: "gemini",
|
Command: "gemini",
|
||||||
Args: []string{"--approval-mode", "yolo"},
|
Args: []string{"--approval-mode", "yolo"},
|
||||||
|
ProcessNames: []string{"gemini"}, // Gemini CLI binary
|
||||||
SessionIDEnv: "GEMINI_SESSION_ID",
|
SessionIDEnv: "GEMINI_SESSION_ID",
|
||||||
ResumeFlag: "--resume",
|
ResumeFlag: "--resume",
|
||||||
ResumeStyle: "flag",
|
ResumeStyle: "flag",
|
||||||
@@ -116,6 +129,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
|||||||
Name: AgentCodex,
|
Name: AgentCodex,
|
||||||
Command: "codex",
|
Command: "codex",
|
||||||
Args: []string{"--yolo"},
|
Args: []string{"--yolo"},
|
||||||
|
ProcessNames: []string{"codex"}, // Codex CLI binary
|
||||||
SessionIDEnv: "", // Codex captures from JSONL output
|
SessionIDEnv: "", // Codex captures from JSONL output
|
||||||
ResumeFlag: "resume",
|
ResumeFlag: "resume",
|
||||||
ResumeStyle: "subcommand",
|
ResumeStyle: "subcommand",
|
||||||
@@ -126,6 +140,43 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
|||||||
OutputFlag: "--json",
|
OutputFlag: "--json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
AgentCursor: {
|
||||||
|
Name: AgentCursor,
|
||||||
|
Command: "cursor-agent",
|
||||||
|
Args: []string{"-f"}, // Force mode (YOLO equivalent), -p requires prompt
|
||||||
|
ProcessNames: []string{"cursor-agent"},
|
||||||
|
SessionIDEnv: "", // Uses --resume with chatId directly
|
||||||
|
ResumeFlag: "--resume",
|
||||||
|
ResumeStyle: "flag",
|
||||||
|
SupportsHooks: false, // TODO: verify hooks support
|
||||||
|
SupportsForkSession: false,
|
||||||
|
NonInteractive: &NonInteractiveConfig{
|
||||||
|
PromptFlag: "-p",
|
||||||
|
OutputFlag: "--output-format json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentAuggie: {
|
||||||
|
Name: AgentAuggie,
|
||||||
|
Command: "auggie",
|
||||||
|
Args: []string{"--allow-indexing"},
|
||||||
|
ProcessNames: []string{"auggie"},
|
||||||
|
SessionIDEnv: "",
|
||||||
|
ResumeFlag: "--resume",
|
||||||
|
ResumeStyle: "flag",
|
||||||
|
SupportsHooks: false,
|
||||||
|
SupportsForkSession: false,
|
||||||
|
},
|
||||||
|
AgentAmp: {
|
||||||
|
Name: AgentAmp,
|
||||||
|
Command: "amp",
|
||||||
|
Args: []string{"--dangerously-allow-all", "--no-ide"},
|
||||||
|
ProcessNames: []string{"amp"},
|
||||||
|
SessionIDEnv: "",
|
||||||
|
ResumeFlag: "threads continue",
|
||||||
|
ResumeStyle: "subcommand", // 'amp threads continue <threadId>'
|
||||||
|
SupportsHooks: false,
|
||||||
|
SupportsForkSession: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry state with proper synchronization.
|
// Registry state with proper synchronization.
|
||||||
@@ -305,6 +356,18 @@ func GetSessionIDEnvVar(agentName string) string {
|
|||||||
return info.SessionIDEnv
|
return info.SessionIDEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProcessNames returns the process names used to detect if an agent is running.
|
||||||
|
// Used by tmux.IsAgentRunning to check pane_current_command.
|
||||||
|
// Returns ["node"] for Claude (default) if agent is not found or has no ProcessNames.
|
||||||
|
func GetProcessNames(agentName string) []string {
|
||||||
|
info := GetAgentPresetByName(agentName)
|
||||||
|
if info == nil || len(info.ProcessNames) == 0 {
|
||||||
|
// Default to Claude's process name for backwards compatibility
|
||||||
|
return []string{"node"}
|
||||||
|
}
|
||||||
|
return info.ProcessNames
|
||||||
|
}
|
||||||
|
|
||||||
// MergeWithPreset applies preset defaults to a RuntimeConfig.
|
// MergeWithPreset applies preset defaults to a RuntimeConfig.
|
||||||
// User-specified values take precedence over preset defaults.
|
// User-specified values take precedence over preset defaults.
|
||||||
// Returns a new RuntimeConfig without modifying the original.
|
// Returns a new RuntimeConfig without modifying the original.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestBuiltinPresets(t *testing.T) {
|
func TestBuiltinPresets(t *testing.T) {
|
||||||
// Ensure all built-in presets are accessible (E2E tested agents only)
|
// Ensure all built-in presets are accessible
|
||||||
presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex}
|
presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex, AgentCursor, AgentAuggie, AgentAmp}
|
||||||
|
|
||||||
for _, preset := range presets {
|
for _, preset := range presets {
|
||||||
info := GetAgentPreset(preset)
|
info := GetAgentPreset(preset)
|
||||||
@@ -22,6 +22,11 @@ func TestBuiltinPresets(t *testing.T) {
|
|||||||
if info.Command == "" {
|
if info.Command == "" {
|
||||||
t.Errorf("preset %s has empty Command", preset)
|
t.Errorf("preset %s has empty Command", preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All presets should have ProcessNames for agent detection
|
||||||
|
if len(info.ProcessNames) == 0 {
|
||||||
|
t.Errorf("preset %s has empty ProcessNames", preset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +39,9 @@ func TestGetAgentPresetByName(t *testing.T) {
|
|||||||
{"claude", AgentClaude, false},
|
{"claude", AgentClaude, false},
|
||||||
{"gemini", AgentGemini, false},
|
{"gemini", AgentGemini, false},
|
||||||
{"codex", AgentCodex, false},
|
{"codex", AgentCodex, false},
|
||||||
|
{"cursor", AgentCursor, false},
|
||||||
|
{"auggie", AgentAuggie, false},
|
||||||
|
{"amp", AgentAmp, false},
|
||||||
{"aider", "", true}, // Not built-in, can be added via config
|
{"aider", "", true}, // Not built-in, can be added via config
|
||||||
{"opencode", "", true}, // Not built-in, can be added via config
|
{"opencode", "", true}, // Not built-in, can be added via config
|
||||||
{"unknown", "", true},
|
{"unknown", "", true},
|
||||||
@@ -63,6 +71,9 @@ func TestRuntimeConfigFromPreset(t *testing.T) {
|
|||||||
{AgentClaude, "claude"},
|
{AgentClaude, "claude"},
|
||||||
{AgentGemini, "gemini"},
|
{AgentGemini, "gemini"},
|
||||||
{AgentCodex, "codex"},
|
{AgentCodex, "codex"},
|
||||||
|
{AgentCursor, "cursor-agent"},
|
||||||
|
{AgentAuggie, "auggie"},
|
||||||
|
{AgentAmp, "amp"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -84,6 +95,9 @@ func TestIsKnownPreset(t *testing.T) {
|
|||||||
{"claude", true},
|
{"claude", true},
|
||||||
{"gemini", true},
|
{"gemini", true},
|
||||||
{"codex", true},
|
{"codex", true},
|
||||||
|
{"cursor", true},
|
||||||
|
{"auggie", true},
|
||||||
|
{"amp", true},
|
||||||
{"aider", false}, // Not built-in, can be added via config
|
{"aider", false}, // Not built-in, can be added via config
|
||||||
{"opencode", false}, // Not built-in, can be added via config
|
{"opencode", false}, // Not built-in, can be added via config
|
||||||
{"unknown", false},
|
{"unknown", false},
|
||||||
@@ -286,6 +300,9 @@ func TestSupportsSessionResume(t *testing.T) {
|
|||||||
{"claude", true},
|
{"claude", true},
|
||||||
{"gemini", true},
|
{"gemini", true},
|
||||||
{"codex", true},
|
{"codex", true},
|
||||||
|
{"cursor", true},
|
||||||
|
{"auggie", true},
|
||||||
|
{"amp", true},
|
||||||
{"unknown", false},
|
{"unknown", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +323,9 @@ func TestGetSessionIDEnvVar(t *testing.T) {
|
|||||||
{"claude", "CLAUDE_SESSION_ID"},
|
{"claude", "CLAUDE_SESSION_ID"},
|
||||||
{"gemini", "GEMINI_SESSION_ID"},
|
{"gemini", "GEMINI_SESSION_ID"},
|
||||||
{"codex", ""}, // Codex uses JSONL output instead
|
{"codex", ""}, // Codex uses JSONL output instead
|
||||||
|
{"cursor", ""}, // Cursor uses --resume with chatId directly
|
||||||
|
{"auggie", ""}, // Auggie uses --resume directly
|
||||||
|
{"amp", ""}, // AMP uses 'threads continue' subcommand
|
||||||
{"unknown", ""},
|
{"unknown", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,3 +337,168 @@ func TestGetSessionIDEnvVar(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProcessNames(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
agentName string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"claude", []string{"node"}},
|
||||||
|
{"gemini", []string{"gemini"}},
|
||||||
|
{"codex", []string{"codex"}},
|
||||||
|
{"cursor", []string{"cursor-agent"}},
|
||||||
|
{"auggie", []string{"auggie"}},
|
||||||
|
{"amp", []string{"amp"}},
|
||||||
|
{"unknown", []string{"node"}}, // Falls back to Claude's process
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.agentName, func(t *testing.T) {
|
||||||
|
got := GetProcessNames(tt.agentName)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("GetProcessNames(%s) = %v, want %v", tt.agentName, got, tt.want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("GetProcessNames(%s)[%d] = %q, want %q", tt.agentName, i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAgentPresetsMatchesConstants(t *testing.T) {
|
||||||
|
// Ensure all AgentPreset constants are returned by ListAgentPresets
|
||||||
|
allConstants := []AgentPreset{AgentClaude, AgentGemini, AgentCodex, AgentCursor, AgentAuggie, AgentAmp}
|
||||||
|
presets := ListAgentPresets()
|
||||||
|
|
||||||
|
// Convert to map for quick lookup
|
||||||
|
presetMap := make(map[string]bool)
|
||||||
|
for _, p := range presets {
|
||||||
|
presetMap[p] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all constants are in the list
|
||||||
|
for _, c := range allConstants {
|
||||||
|
if !presetMap[string(c)] {
|
||||||
|
t.Errorf("ListAgentPresets() missing constant %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no empty names
|
||||||
|
for _, p := range presets {
|
||||||
|
if p == "" {
|
||||||
|
t.Error("ListAgentPresets() contains empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentCommandGeneration(t *testing.T) {
|
||||||
|
// Test full command line generation for each agent
|
||||||
|
tests := []struct {
|
||||||
|
preset AgentPreset
|
||||||
|
wantCommand string
|
||||||
|
wantContains []string // Args that should be present
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
preset: AgentClaude,
|
||||||
|
wantCommand: "claude",
|
||||||
|
wantContains: []string{"--dangerously-skip-permissions"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: AgentGemini,
|
||||||
|
wantCommand: "gemini",
|
||||||
|
wantContains: []string{"--approval-mode", "yolo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: AgentCodex,
|
||||||
|
wantCommand: "codex",
|
||||||
|
wantContains: []string{"--yolo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: AgentCursor,
|
||||||
|
wantCommand: "cursor-agent",
|
||||||
|
wantContains: []string{"-f"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: AgentAuggie,
|
||||||
|
wantCommand: "auggie",
|
||||||
|
wantContains: []string{"--allow-indexing"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: AgentAmp,
|
||||||
|
wantCommand: "amp",
|
||||||
|
wantContains: []string{"--dangerously-allow-all", "--no-ide"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.preset), func(t *testing.T) {
|
||||||
|
rc := RuntimeConfigFromPreset(tt.preset)
|
||||||
|
if rc == nil {
|
||||||
|
t.Fatal("RuntimeConfigFromPreset returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Command != tt.wantCommand {
|
||||||
|
t.Errorf("Command = %q, want %q", rc.Command, tt.wantCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required args are present
|
||||||
|
argsStr := strings.Join(rc.Args, " ")
|
||||||
|
for _, arg := range tt.wantContains {
|
||||||
|
found := false
|
||||||
|
for _, a := range rc.Args {
|
||||||
|
if a == arg {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Args %q missing expected %q", argsStr, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorAgentPreset(t *testing.T) {
|
||||||
|
// Verify cursor agent preset is correctly configured
|
||||||
|
info := GetAgentPreset(AgentCursor)
|
||||||
|
if info == nil {
|
||||||
|
t.Fatal("cursor preset not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check command
|
||||||
|
if info.Command != "cursor-agent" {
|
||||||
|
t.Errorf("cursor command = %q, want cursor-agent", info.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check YOLO-equivalent flag (-f for force mode)
|
||||||
|
// Note: -p is for non-interactive mode with prompt, not used for default Args
|
||||||
|
hasF := false
|
||||||
|
for _, arg := range info.Args {
|
||||||
|
if arg == "-f" {
|
||||||
|
hasF = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasF {
|
||||||
|
t.Error("cursor args missing -f (force/YOLO mode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ProcessNames for detection
|
||||||
|
if len(info.ProcessNames) == 0 {
|
||||||
|
t.Error("cursor ProcessNames is empty")
|
||||||
|
}
|
||||||
|
if info.ProcessNames[0] != "cursor-agent" {
|
||||||
|
t.Errorf("cursor ProcessNames[0] = %q, want cursor-agent", info.ProcessNames[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check resume support
|
||||||
|
if info.ResumeFlag != "--resume" {
|
||||||
|
t.Errorf("cursor ResumeFlag = %q, want --resume", info.ResumeFlag)
|
||||||
|
}
|
||||||
|
if info.ResumeStyle != "flag" {
|
||||||
|
t.Errorf("cursor ResumeStyle = %q, want flag", info.ResumeStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type TownSettings struct {
|
|||||||
Version int `json:"version"` // schema version
|
Version int `json:"version"` // schema version
|
||||||
|
|
||||||
// DefaultAgent is the name of the agent preset to use by default.
|
// DefaultAgent is the name of the agent preset to use by default.
|
||||||
// Can be a built-in preset ("claude", "gemini", "codex")
|
// Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp")
|
||||||
// or a custom agent name defined in settings/agents.json.
|
// or a custom agent name defined in settings/agents.json.
|
||||||
// Default: "claude"
|
// Default: "claude"
|
||||||
DefaultAgent string `json:"default_agent,omitempty"`
|
DefaultAgent string `json:"default_agent,omitempty"`
|
||||||
@@ -190,7 +190,7 @@ type RigSettings struct {
|
|||||||
Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings (deprecated: use Agent)
|
Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings (deprecated: use Agent)
|
||||||
|
|
||||||
// Agent selects which agent preset to use for this rig.
|
// Agent selects which agent preset to use for this rig.
|
||||||
// Can be a built-in preset ("claude", "gemini", "codex")
|
// Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp")
|
||||||
// or a custom agent defined in settings/agents.json.
|
// or a custom agent defined in settings/agents.json.
|
||||||
// If empty, uses the town's default_agent setting.
|
// If empty, uses the town's default_agent setting.
|
||||||
// Takes precedence over Runtime if both are set.
|
// Takes precedence over Runtime if both are set.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/claude"
|
"github.com/steveyegge/gastown/internal/claude"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -477,14 +478,11 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Town-root files were inherited by ALL agents via directory traversal.
|
// Town-root files were inherited by ALL agents via directory traversal.
|
||||||
// Cycle all Gas Town sessions so they pick up the corrected file locations.
|
// Warn user to restart agents - don't auto-kill sessions as that's too disruptive,
|
||||||
// This includes gt-* (rig agents) and hq-* (mayor, deacon).
|
// especially since deacon runs gt doctor automatically which would create a loop.
|
||||||
sessions, _ := t.ListSessions()
|
// Settings are only read at startup, so running agents already have config loaded.
|
||||||
for _, sess := range sessions {
|
fmt.Printf("\n %s Town-root settings were moved. Restart agents to pick up new config:\n", style.Warning.Render("⚠"))
|
||||||
if strings.HasPrefix(sess, session.Prefix) || strings.HasPrefix(sess, session.HQPrefix) {
|
fmt.Printf(" gt up --restart\n\n")
|
||||||
_ = t.KillSession(sess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1016,3 +1016,69 @@ func TestClaudeSettingsCheck_FixMovesCLAUDEmdToMayor(t *testing.T) {
|
|||||||
t.Error("expected CLAUDE.md to be created at mayor/")
|
t.Error("expected CLAUDE.md to be created at mayor/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_TownRootSettingsWarnsInsteadOfKilling(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create mayor directory (needed for fix to recreate settings there)
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings.json at town root (wrong location - pollutes all agents)
|
||||||
|
staleTownRootDir := filepath.Join(tmpDir, ".claude")
|
||||||
|
if err := os.MkdirAll(staleTownRootDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
staleTownRootSettings := filepath.Join(staleTownRootDir, "settings.json")
|
||||||
|
// Create valid settings content
|
||||||
|
settingsContent := `{
|
||||||
|
"env": {"PATH": "/usr/bin"},
|
||||||
|
"enabledPlugins": ["claude-code-expert"],
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "gt prime"}]}],
|
||||||
|
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "gt handoff"}]}]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(staleTownRootSettings, []byte(settingsContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewClaudeSettingsCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
|
||||||
|
// Run to detect
|
||||||
|
result := check.Run(ctx)
|
||||||
|
if result.Status != StatusError {
|
||||||
|
t.Fatalf("expected StatusError for town root settings, got %v", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's flagged as wrong location
|
||||||
|
foundWrongLocation := false
|
||||||
|
for _, d := range result.Details {
|
||||||
|
if strings.Contains(d, "wrong location") {
|
||||||
|
foundWrongLocation = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundWrongLocation {
|
||||||
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply fix - should NOT return error and should NOT kill sessions
|
||||||
|
// (session killing would require tmux which isn't available in tests)
|
||||||
|
if err := check.Fix(ctx); err != nil {
|
||||||
|
t.Fatalf("Fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify stale file was deleted
|
||||||
|
if _, err := os.Stat(staleTownRootSettings); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected settings.json at town root to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .claude directory was cleaned up (best-effort)
|
||||||
|
if _, err := os.Stat(staleTownRootDir); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected .claude directory at town root to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1034,6 +1034,10 @@ func (c *BeadsRedirectCheck) Fix(ctx *CheckContext) error {
|
|||||||
// Continue - minimal config created
|
// Continue - minimal config created
|
||||||
} else {
|
} else {
|
||||||
_ = output // bd init succeeded
|
_ = output // bd init succeeded
|
||||||
|
// Configure custom types for Gas Town (beads v0.46.0+)
|
||||||
|
configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||||
|
configCmd.Dir = rigPath
|
||||||
|
_, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package polecat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -144,6 +145,13 @@ func TestAssigneeID(t *testing.T) {
|
|||||||
func TestGetReturnsWorkingWithoutBeads(t *testing.T) {
|
func TestGetReturnsWorkingWithoutBeads(t *testing.T) {
|
||||||
// When beads is not available, Get should return StateWorking
|
// When beads is not available, Get should return StateWorking
|
||||||
// (assume the polecat is doing something if it exists)
|
// (assume the polecat is doing something if it exists)
|
||||||
|
//
|
||||||
|
// Skip if bd is installed - the test assumes bd is unavailable, but when bd
|
||||||
|
// is present it queries beads and returns actual state instead of defaulting.
|
||||||
|
if _, err := exec.LookPath("bd"); err == nil {
|
||||||
|
t.Skip("skipping: bd is installed, test requires bd to be unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
polecatDir := filepath.Join(root, "polecats", "Test")
|
polecatDir := filepath.Join(root, "polecats", "Test")
|
||||||
if err := os.MkdirAll(polecatDir, 0755); err != nil {
|
if err := os.MkdirAll(polecatDir, 0755); err != nil {
|
||||||
|
|||||||
@@ -359,6 +359,10 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
|||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output)))
|
fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output)))
|
||||||
}
|
}
|
||||||
|
// Configure custom types for Gas Town (beads v0.46.0+)
|
||||||
|
configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||||
|
configCmd.Dir = mayorRigPath
|
||||||
|
_, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,6 +583,14 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure custom types for Gas Town (agent, role, rig, convoy).
|
||||||
|
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
||||||
|
configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event")
|
||||||
|
configCmd.Dir = rigPath
|
||||||
|
configCmd.Env = filteredEnv
|
||||||
|
// Ignore errors - older beads versions don't need this
|
||||||
|
_, _ = configCmd.CombinedOutput()
|
||||||
|
|
||||||
// Ensure database has repository fingerprint (GH #25).
|
// Ensure database has repository fingerprint (GH #25).
|
||||||
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
||||||
// Without fingerprint, the bd daemon fails to start silently.
|
// Without fingerprint, the bd daemon fails to start silently.
|
||||||
|
|||||||
@@ -309,3 +309,119 @@ func TestEnsureSessionFresh_IdempotentOnZombie(t *testing.T) {
|
|||||||
t.Error("expected session to exist after multiple EnsureSessionFresh calls")
|
t.Error("expected session to exist after multiple EnsureSessionFresh calls")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsAgentRunning(t *testing.T) {
|
||||||
|
if !hasTmux() {
|
||||||
|
t.Skip("tmux not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
tm := NewTmux()
|
||||||
|
sessionName := "gt-test-agent-" + t.Name()
|
||||||
|
|
||||||
|
// Clean up any existing session
|
||||||
|
_ = tm.KillSession(sessionName)
|
||||||
|
|
||||||
|
// Create session (will run default shell)
|
||||||
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
||||||
|
t.Fatalf("NewSession: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tm.KillSession(sessionName) }()
|
||||||
|
|
||||||
|
// Get the current pane command (should be bash/zsh/etc)
|
||||||
|
cmd, err := tm.GetPaneCommand(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPaneCommand: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
processNames []string
|
||||||
|
wantRunning bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty process list",
|
||||||
|
processNames: []string{},
|
||||||
|
wantRunning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching shell process",
|
||||||
|
processNames: []string{cmd}, // Current shell
|
||||||
|
wantRunning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude agent (node) - not running",
|
||||||
|
processNames: []string{"node"},
|
||||||
|
wantRunning: cmd == "node", // Only true if shell happens to be node
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gemini agent - not running",
|
||||||
|
processNames: []string{"gemini"},
|
||||||
|
wantRunning: cmd == "gemini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cursor agent - not running",
|
||||||
|
processNames: []string{"cursor-agent"},
|
||||||
|
wantRunning: cmd == "cursor-agent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple process names with match",
|
||||||
|
processNames: []string{"nonexistent", cmd, "also-nonexistent"},
|
||||||
|
wantRunning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple process names without match",
|
||||||
|
processNames: []string{"nonexistent1", "nonexistent2"},
|
||||||
|
wantRunning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tm.IsAgentRunning(sessionName, tt.processNames...)
|
||||||
|
if got != tt.wantRunning {
|
||||||
|
t.Errorf("IsAgentRunning(%q, %v) = %v, want %v (current cmd: %q)",
|
||||||
|
sessionName, tt.processNames, got, tt.wantRunning, cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAgentRunning_NonexistentSession(t *testing.T) {
|
||||||
|
if !hasTmux() {
|
||||||
|
t.Skip("tmux not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
tm := NewTmux()
|
||||||
|
|
||||||
|
// IsAgentRunning on nonexistent session should return false, not error
|
||||||
|
got := tm.IsAgentRunning("nonexistent-session-xyz", "node", "gemini", "cursor-agent")
|
||||||
|
if got {
|
||||||
|
t.Error("IsAgentRunning on nonexistent session should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsClaudeRunning(t *testing.T) {
|
||||||
|
if !hasTmux() {
|
||||||
|
t.Skip("tmux not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
tm := NewTmux()
|
||||||
|
sessionName := "gt-test-claude-" + t.Name()
|
||||||
|
|
||||||
|
// Clean up any existing session
|
||||||
|
_ = tm.KillSession(sessionName)
|
||||||
|
|
||||||
|
// Create session (will run default shell, not Claude)
|
||||||
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
||||||
|
t.Fatalf("NewSession: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tm.KillSession(sessionName) }()
|
||||||
|
|
||||||
|
// IsClaudeRunning should be false (shell is running, not node)
|
||||||
|
cmd, _ := tm.GetPaneCommand(sessionName)
|
||||||
|
wantRunning := cmd == "node"
|
||||||
|
|
||||||
|
if got := tm.IsClaudeRunning(sessionName); got != wantRunning {
|
||||||
|
t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user