feat: crew attach auto-detection, worktree polecats, beads mail

- gt crew at: auto-detect crew from cwd, run gt prime after launch
- Polecats now use git worktrees from refinery (faster than clones)
- Updated architecture.md for two-tier beads mail model
- Town beads (gm-*) for Mayor mail/coordination
- Rig .beads/ symlinks to refinery/rig/.beads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-17 19:51:36 -08:00
parent cb04302e7b
commit 691971a16a
18 changed files with 343 additions and 160 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -83,7 +84,7 @@ Examples:
}
var crewAtCmd = &cobra.Command{
Use: "at <name>",
Use: "at [name]",
Aliases: []string{"attach"},
Short: "Attach to crew workspace session",
Long: `Start or attach to a tmux session for a crew workspace.
@@ -91,10 +92,16 @@ var crewAtCmd = &cobra.Command{
Creates a new tmux session if none exists, or attaches to existing.
Use --no-tmux to just print the directory path instead.
Role Discovery:
If no name is provided, attempts to detect the crew workspace from the
current directory. If you're in <rig>/crew/<name>/, it will attach to
that workspace automatically.
Examples:
gt crew at dave # Attach to dave's session
gt crew at # Auto-detect from cwd
gt crew at dave --no-tmux # Just print path`,
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
RunE: runCrewAt,
}
@@ -184,7 +191,7 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
@@ -274,7 +281,7 @@ func getCrewManager(rigName string) (*crew.Manager, *rig.Rig, error) {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
@@ -386,7 +393,23 @@ func runCrewList(cmd *cobra.Command, args []string) error {
}
func runCrewAt(cmd *cobra.Command, args []string) error {
name := args[0]
var name string
// Determine crew name: from arg, or auto-detect from cwd
if len(args) > 0 {
name = args[0]
} else {
// Try to detect from current directory
detected, err := detectCrewFromCwd()
if err != nil {
return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at <name>", err)
}
name = detected.crewName
if crewRig == "" {
crewRig = detected.rigName
}
fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name)
}
crewMgr, r, err := getCrewManager(crewRig)
if err != nil {
@@ -426,17 +449,89 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
t.SetEnvironment(sessionID, "GT_CREW", name)
// Start claude
if err := t.SendKeys(sessionID, "claude"); err != nil {
// Start claude with skip permissions (crew workers are trusted like Mayor)
if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
// Wait a moment for Claude to initialize, then prime it
// We send gt prime after a short delay to ensure Claude is ready
if err := t.SendKeysDelayed(sessionID, "gt prime", 2000); err != nil {
// Non-fatal: Claude started but priming failed
fmt.Printf("Warning: Could not send prime command: %v\n", err)
}
fmt.Printf("%s Created session for %s/%s\n",
style.Bold.Render("✓"), r.Name, name)
}
// Attach to session
return t.AttachSession(sessionID)
// Attach to session using exec to properly forward TTY
return attachToTmuxSession(sessionID)
}
// attachToTmuxSession attaches to a tmux session with proper TTY forwarding.
func attachToTmuxSession(sessionID string) error {
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return fmt.Errorf("tmux not found: %w", err)
}
cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// crewDetection holds the result of detecting crew workspace from cwd.
type crewDetection struct {
rigName string
crewName string
}
// detectCrewFromCwd attempts to detect the crew workspace from the current directory.
// It looks for the pattern <town>/<rig>/crew/<name>/ in the current path.
func detectCrewFromCwd() (*crewDetection, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("getting cwd: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return nil, fmt.Errorf("not in Gas Town workspace: %w", err)
}
if townRoot == "" {
return nil, fmt.Errorf("not in Gas Town workspace")
}
// Get relative path from town root
relPath, err := filepath.Rel(townRoot, cwd)
if err != nil {
return nil, fmt.Errorf("getting relative path: %w", err)
}
// Normalize and split path
relPath = filepath.ToSlash(relPath)
parts := strings.Split(relPath, "/")
// Look for pattern: <rig>/crew/<name>/...
// Minimum: rig, crew, name = 3 parts
if len(parts) < 3 {
return nil, fmt.Errorf("not in a crew workspace (path too short)")
}
rigName := parts[0]
if parts[1] != "crew" {
return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)")
}
crewName := parts[2]
return &crewDetection{
rigName: rigName,
crewName: crewName,
}, nil
}
func runCrewRemove(cmd *cobra.Command, args []string) error {

View File

@@ -26,9 +26,9 @@ var installCmd = &cobra.Command{
Long: `Create a new Gas Town harness at the specified path.
A harness is the top-level directory where Gas Town is installed. It contains:
- config/town.json Town configuration
- config/rigs.json Registry of managed rigs
- mayor/ Mayor agent home
- CLAUDE.md Mayor role context (Mayor runs from harness root)
- mayor/ Mayor config, state, and mail
- rigs/ Managed rig clones (created by 'gt rig add')
- .beads/redirect (optional) Default beads location
If path is omitted, uses the current directory.
@@ -94,43 +94,43 @@ func runInstall(cmd *cobra.Command, args []string) error {
return fmt.Errorf("creating directory: %w", err)
}
// Create config directory
configDir := filepath.Join(absPath, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
// Create mayor directory (holds config, state, and mail)
mayorDir := filepath.Join(absPath, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
}
fmt.Printf(" ✓ Created config/\n")
fmt.Printf(" ✓ Created mayor/\n")
// Create town.json
// Create town.json in mayor/
townConfig := &config.TownConfig{
Type: "town",
Version: config.CurrentTownVersion,
Name: townName,
CreatedAt: time.Now(),
}
townPath := filepath.Join(configDir, "town.json")
townPath := filepath.Join(mayorDir, "town.json")
if err := config.SaveTownConfig(townPath, townConfig); err != nil {
return fmt.Errorf("writing town.json: %w", err)
}
fmt.Printf(" ✓ Created config/town.json\n")
fmt.Printf(" ✓ Created mayor/town.json\n")
// Create rigs.json
// Create rigs.json in mayor/
rigsConfig := &config.RigsConfig{
Version: config.CurrentRigsVersion,
Rigs: make(map[string]config.RigEntry),
}
rigsPath := filepath.Join(configDir, "rigs.json")
rigsPath := filepath.Join(mayorDir, "rigs.json")
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
return fmt.Errorf("writing rigs.json: %w", err)
}
fmt.Printf(" ✓ Created config/rigs.json\n")
fmt.Printf(" ✓ Created mayor/rigs.json\n")
// Create mayor directory
mayorDir := filepath.Join(absPath, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
// Create rigs directory (for managed rig clones)
rigsDir := filepath.Join(absPath, "rigs")
if err := os.MkdirAll(rigsDir, 0755); err != nil {
return fmt.Errorf("creating rigs directory: %w", err)
}
fmt.Printf(" ✓ Created mayor/\n")
fmt.Printf(" ✓ Created rigs/\n")
// Create mayor mail directory
mailDir := filepath.Join(mayorDir, "mail")
@@ -156,22 +156,11 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
fmt.Printf(" ✓ Created mayor/state.json\n")
// Create mayor config.json (this is what distinguishes town-level mayor)
mayorConfig := map[string]interface{}{
"type": "mayor",
"version": 1,
}
mayorConfigPath := filepath.Join(mayorDir, "config.json")
if err := writeJSON(mayorConfigPath, mayorConfig); err != nil {
return fmt.Errorf("writing mayor config: %w", err)
}
fmt.Printf(" ✓ Created mayor/config.json\n")
// Create Mayor CLAUDE.md from template
if err := createMayorCLAUDEmd(mayorDir, absPath); err != nil {
// Create Mayor CLAUDE.md at harness root (Mayor runs from there)
if err := createMayorCLAUDEmd(absPath, absPath); err != nil {
fmt.Printf(" %s Could not create CLAUDE.md: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created mayor/CLAUDE.md\n")
fmt.Printf(" ✓ Created CLAUDE.md\n")
}
// Create .beads directory with redirect (optional)
@@ -201,7 +190,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
return nil
}
func createMayorCLAUDEmd(mayorDir, townRoot string) error {
func createMayorCLAUDEmd(harnessRoot, townRoot string) error {
tmpl, err := templates.New()
if err != nil {
return err
@@ -210,7 +199,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error {
data := templates.RoleData{
Role: "mayor",
TownRoot: townRoot,
WorkDir: mayorDir,
WorkDir: harnessRoot,
}
content, err := tmpl.RenderRole("mayor", data)
@@ -218,7 +207,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error {
return err
}
claudePath := filepath.Join(mayorDir, "CLAUDE.md")
claudePath := filepath.Join(harnessRoot, "CLAUDE.md")
return os.WriteFile(claudePath, []byte(content), 0644)
}

View File

@@ -145,7 +145,7 @@ func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
@@ -303,8 +303,8 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
fmt.Printf("Removing polecat %s/%s...\n", rigName, polecatName)
if err := mgr.Remove(polecatName); err != nil {
if errors.Is(err, polecat.ErrHasChanges) && !polecatForce {
if err := mgr.Remove(polecatName, polecatForce); err != nil {
if errors.Is(err, polecat.ErrHasChanges) {
return fmt.Errorf("polecat has uncommitted changes. Use --force to remove anyway")
}
return fmt.Errorf("removing polecat: %w", err)

View File

@@ -102,7 +102,7 @@ func getRefineryManager(rigName string) (*refinery.Manager, *rig.Rig, error) {
return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}

View File

@@ -154,7 +154,7 @@ func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
@@ -269,7 +269,7 @@ func runSessionList(cmd *cobra.Command, args []string) error {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}

View File

@@ -81,7 +81,7 @@ func runSpawn(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}

View File

@@ -63,7 +63,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
// Load town config
townConfigPath := filepath.Join(townRoot, "config", "town.json")
townConfigPath := filepath.Join(townRoot, "mayor", "town.json")
townConfig, err := config.LoadTownConfig(townConfigPath)
if err != nil {
// Try to continue without config
@@ -71,7 +71,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
// Empty config if file doesn't exist

View File

@@ -63,7 +63,7 @@ func runStop(cmd *cobra.Command, args []string) error {
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}

View File

@@ -189,7 +189,7 @@ func getSwarmRig(rigName string) (*rig.Rig, string, error) {
return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err)
}
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
@@ -212,7 +212,7 @@ func getAllRigs() ([]*rig.Rig, string, error) {
return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err)
}
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}

View File

@@ -8,7 +8,7 @@ import (
func TestTownConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config", "town.json")
path := filepath.Join(dir, "mayor", "town.json")
original := &TownConfig{
Type: "town",
@@ -36,7 +36,7 @@ func TestTownConfigRoundTrip(t *testing.T) {
func TestRigsConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config", "rigs.json")
path := filepath.Join(dir, "mayor", "rigs.json")
original := &RigsConfig{
Version: 1,

View File

@@ -3,7 +3,7 @@ package config
import "time"
// TownConfig represents the main town configuration (config/town.json).
// TownConfig represents the main town configuration (mayor/town.json).
type TownConfig struct {
Type string `json:"type"` // "town"
Version int `json:"version"` // schema version
@@ -11,7 +11,7 @@ type TownConfig struct {
CreatedAt time.Time `json:"created_at"`
}
// RigsConfig represents the rigs registry (config/rigs.json).
// RigsConfig represents the rigs registry (mayor/rigs.json).
type RigsConfig struct {
Version int `json:"version"`
Rigs map[string]RigEntry `json:"rigs"`

View File

@@ -246,3 +246,82 @@ func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) {
}
return true, nil
}
// WorktreeAdd creates a new worktree at the given path with a new branch.
// The new branch is created from the current HEAD.
func (g *Git) WorktreeAdd(path, branch string) error {
_, err := g.run("worktree", "add", "-b", branch, path)
return err
}
// WorktreeAddDetached creates a new worktree at the given path with a detached HEAD.
func (g *Git) WorktreeAddDetached(path, ref string) error {
_, err := g.run("worktree", "add", "--detach", path, ref)
return err
}
// WorktreeAddExisting creates a new worktree at the given path for an existing branch.
func (g *Git) WorktreeAddExisting(path, branch string) error {
_, err := g.run("worktree", "add", path, branch)
return err
}
// WorktreeRemove removes a worktree.
func (g *Git) WorktreeRemove(path string, force bool) error {
args := []string{"worktree", "remove", path}
if force {
args = append(args, "--force")
}
_, err := g.run(args...)
return err
}
// WorktreePrune removes worktree entries for deleted paths.
func (g *Git) WorktreePrune() error {
_, err := g.run("worktree", "prune")
return err
}
// Worktree represents a git worktree.
type Worktree struct {
Path string
Branch string
Commit string
}
// WorktreeList returns all worktrees for this repository.
func (g *Git) WorktreeList() ([]Worktree, error) {
out, err := g.run("worktree", "list", "--porcelain")
if err != nil {
return nil, err
}
var worktrees []Worktree
var current Worktree
for _, line := range strings.Split(out, "\n") {
if line == "" {
if current.Path != "" {
worktrees = append(worktrees, current)
current = Worktree{}
}
continue
}
switch {
case strings.HasPrefix(line, "worktree "):
current.Path = strings.TrimPrefix(line, "worktree ")
case strings.HasPrefix(line, "HEAD "):
current.Commit = strings.TrimPrefix(line, "HEAD ")
case strings.HasPrefix(line, "branch "):
current.Branch = strings.TrimPrefix(line, "branch refs/heads/")
}
}
// Don't forget the last one
if current.Path != "" {
worktrees = append(worktrees, current)
}
return worktrees, nil
}

View File

@@ -49,13 +49,15 @@ func (m *Manager) exists(name string) bool {
return err == nil
}
// Add creates a new polecat with a clone of the rig.
// Add creates a new polecat as a git worktree from the refinery clone.
// This is much faster than a full clone and shares objects with the refinery.
func (m *Manager) Add(name string) (*Polecat, error) {
if m.exists(name) {
return nil, ErrPolecatExists
}
polecatPath := m.polecatDir(name)
branchName := fmt.Sprintf("polecat/%s", name)
// Create polecats directory if needed
polecatsDir := filepath.Join(m.rig.Path, "polecats")
@@ -63,21 +65,19 @@ func (m *Manager) Add(name string) (*Polecat, error) {
return nil, fmt.Errorf("creating polecats dir: %w", err)
}
// Clone the rig repo
if err := m.git.Clone(m.rig.GitURL, polecatPath); err != nil {
return nil, fmt.Errorf("cloning rig: %w", err)
// Use refinery clone as the base for worktrees
refineryPath := filepath.Join(m.rig.Path, "refinery", "rig")
refineryGit := git.NewGit(refineryPath)
// Verify refinery clone exists
if _, err := os.Stat(refineryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("refinery clone not found at %s (run 'gt rig add' to set up rig structure)", refineryPath)
}
// Create working branch
polecatGit := git.NewGit(polecatPath)
branchName := fmt.Sprintf("polecat/%s", name)
if err := polecatGit.CreateBranch(branchName); err != nil {
os.RemoveAll(polecatPath)
return nil, fmt.Errorf("creating branch: %w", err)
}
if err := polecatGit.Checkout(branchName); err != nil {
os.RemoveAll(polecatPath)
return nil, fmt.Errorf("checking out branch: %w", err)
// Create worktree with new branch
// git worktree add -b polecat/<name> <path>
if err := refineryGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree: %w", err)
}
// Create polecat state
@@ -94,15 +94,17 @@ func (m *Manager) Add(name string) (*Polecat, error) {
// Save state
if err := m.saveState(polecat); err != nil {
os.RemoveAll(polecatPath)
// Clean up worktree on failure
refineryGit.WorktreeRemove(polecatPath, true)
return nil, fmt.Errorf("saving state: %w", err)
}
return polecat, nil
}
// Remove deletes a polecat.
func (m *Manager) Remove(name string) error {
// Remove deletes a polecat worktree.
// If force is true, removes even with uncommitted changes.
func (m *Manager) Remove(name string, force bool) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
@@ -110,17 +112,30 @@ func (m *Manager) Remove(name string) error {
polecatPath := m.polecatDir(name)
polecatGit := git.NewGit(polecatPath)
// Check for uncommitted changes
hasChanges, err := polecatGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
// Check for uncommitted changes unless force
if !force {
hasChanges, err := polecatGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
}
}
// Remove directory
if err := os.RemoveAll(polecatPath); err != nil {
return fmt.Errorf("removing polecat dir: %w", err)
// Use refinery to remove the worktree properly
refineryPath := filepath.Join(m.rig.Path, "refinery", "rig")
refineryGit := git.NewGit(refineryPath)
// Try to remove as a worktree first (use force flag for worktree removal too)
if err := refineryGit.WorktreeRemove(polecatPath, force); err != nil {
// Fall back to direct removal if worktree removal fails
// (e.g., if this is an old-style clone, not a worktree)
if removeErr := os.RemoveAll(polecatPath); removeErr != nil {
return fmt.Errorf("removing polecat dir: %w", removeErr)
}
}
// Prune any stale worktree entries
refineryGit.WorktreePrune()
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os/exec"
"strings"
"time"
)
// Common errors
@@ -125,6 +126,13 @@ func (t *Tmux) SendKeysRaw(session, keys string) error {
return err
}
// SendKeysDelayed sends keystrokes after a delay (in milliseconds).
// Useful for waiting for a process to be ready before sending input.
func (t *Tmux) SendKeysDelayed(session, keys string, delayMs int) error {
time.Sleep(time.Duration(delayMs) * time.Millisecond)
return t.SendKeys(session, keys)
}
// CapturePane captures the visible content of a pane.
func (t *Tmux) CapturePane(session string, lines int) (string, error) {
return t.run("capture-pane", "-p", "-t", session, "-S", fmt.Sprintf("-%d", lines))

View File

@@ -14,11 +14,8 @@ var ErrNotFound = errors.New("not in a Gas Town workspace")
// Markers used to detect a Gas Town workspace.
const (
// PrimaryMarker is the main config file that identifies a workspace.
PrimaryMarker = "config/town.json"
// AlternativePrimaryMarker is the town-level mayor config file.
// This distinguishes a town mayor from a rig-level mayor clone.
AlternativePrimaryMarker = "mayor/config.json"
// The town.json file lives in mayor/ along with other mayor config.
PrimaryMarker = "mayor/town.json"
// SecondaryMarker is an alternative indicator at the town level.
// Note: This can match rig-level mayors too, so we continue searching
@@ -27,8 +24,7 @@ const (
)
// Find locates the town root by walking up from the given directory.
// It looks for config/town.json or mayor/config.json (primary markers)
// or mayor/ directory (secondary marker).
// It looks for mayor/town.json (primary marker) or mayor/ directory (secondary marker).
//
// To avoid matching rig-level mayor directories, we continue searching
// upward after finding a secondary marker, preferring primary matches.
@@ -50,19 +46,12 @@ func Find(startDir string) (string, error) {
// Walk up the directory tree
current := absDir
for {
// Check for primary marker (config/town.json)
// Check for primary marker (mayor/town.json)
primaryPath := filepath.Join(current, PrimaryMarker)
if _, err := os.Stat(primaryPath); err == nil {
return current, nil
}
// Check for alternative primary marker (mayor/config.json)
// This distinguishes a town-level mayor from a rig-level mayor clone
altPrimaryPath := filepath.Join(current, AlternativePrimaryMarker)
if _, err := os.Stat(altPrimaryPath); err == nil {
return current, nil
}
// Check for secondary marker (mayor/ directory)
// Don't return immediately - continue searching for primary markers
if secondaryMatch == "" {
@@ -114,27 +103,21 @@ func FindFromCwdOrError() (string, error) {
}
// IsWorkspace checks if the given directory is a Gas Town workspace root.
// A directory is a workspace if it has primary markers (config/town.json
// or mayor/config.json) or a secondary marker (mayor/ directory).
// A directory is a workspace if it has a primary marker (mayor/town.json)
// or a secondary marker (mayor/ directory).
func IsWorkspace(dir string) (bool, error) {
absDir, err := filepath.Abs(dir)
if err != nil {
return false, fmt.Errorf("resolving path: %w", err)
}
// Check for primary marker
// Check for primary marker (mayor/town.json)
primaryPath := filepath.Join(absDir, PrimaryMarker)
if _, err := os.Stat(primaryPath); err == nil {
return true, nil
}
// Check for alternative primary marker
altPrimaryPath := filepath.Join(absDir, AlternativePrimaryMarker)
if _, err := os.Stat(altPrimaryPath); err == nil {
return true, nil
}
// Check for secondary marker
// Check for secondary marker (mayor/ directory)
secondaryPath := filepath.Join(absDir, SecondaryMarker)
info, err := os.Stat(secondaryPath)
if err == nil && info.IsDir() {

View File

@@ -18,11 +18,11 @@ func realPath(t *testing.T, path string) string {
func TestFindWithPrimaryMarker(t *testing.T) {
// Create temp workspace structure
root := realPath(t, t.TempDir())
configDir := filepath.Join(root, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
mayorDir := filepath.Join(root, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
townFile := filepath.Join(configDir, "town.json")
townFile := filepath.Join(mayorDir, "town.json")
if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -92,11 +92,11 @@ func TestFindOrErrorNotFound(t *testing.T) {
func TestFindAtRoot(t *testing.T) {
// Create workspace at temp root level
root := realPath(t, t.TempDir())
configDir := filepath.Join(root, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
mayorDir := filepath.Join(root, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
townFile := filepath.Join(configDir, "town.json")
townFile := filepath.Join(mayorDir, "town.json")
if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -123,12 +123,12 @@ func TestIsWorkspace(t *testing.T) {
t.Error("expected not a workspace initially")
}
// Add primary marker
configDir := filepath.Join(root, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
// Add primary marker (mayor/town.json)
mayorDir := filepath.Join(root, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
townFile := filepath.Join(configDir, "town.json")
townFile := filepath.Join(mayorDir, "town.json")
if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -146,11 +146,11 @@ func TestIsWorkspace(t *testing.T) {
func TestFindFollowsSymlinks(t *testing.T) {
// Create workspace
root := realPath(t, t.TempDir())
configDir := filepath.Join(root, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
mayorDir := filepath.Join(root, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
townFile := filepath.Join(configDir, "town.json")
townFile := filepath.Join(mayorDir, "town.json")
if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil {
t.Fatalf("write: %v", err)
}