codex
This commit is contained in:
committed by
Cameron Palmer
parent
f4cbcb4ce9
commit
38adfa4d8b
43
README.md
43
README.md
@@ -85,7 +85,8 @@ Git-backed issue tracking system that stores work state as structured data.
|
|||||||
- **Git 2.25+** - for worktree support
|
- **Git 2.25+** - for worktree support
|
||||||
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
|
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
|
||||||
- **tmux 3.0+** - recommended for full experience
|
- **tmux 3.0+** - recommended for full experience
|
||||||
- **Claude Code CLI** - [claude.ai/code](https://claude.ai/code)
|
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
|
||||||
|
- **Codex CLI** (optional runtime) - [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -180,6 +181,18 @@ gt convoy create "Auth System" issue-101 issue-102 --notify
|
|||||||
gt convoy list
|
gt convoy list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Minimal Mode (No Tmux)
|
||||||
|
|
||||||
|
Run individual runtime instances manually. Gas Town just tracks state.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt convoy create "Fix bugs" issue-123 # Create convoy (sling auto-creates if skipped)
|
||||||
|
gt sling issue-123 myproject # Assign to worker
|
||||||
|
claude --resume # Agent reads mail, runs work (Claude)
|
||||||
|
# or: codex # Start Codex in the workspace
|
||||||
|
gt convoy list # Check progress
|
||||||
|
```
|
||||||
|
|
||||||
### Beads Formula Workflow
|
### Beads Formula Workflow
|
||||||
|
|
||||||
**Best for:** Predefined, repeatable processes
|
**Best for:** Predefined, repeatable processes
|
||||||
@@ -258,6 +271,30 @@ gt sling bug-101 myproject/my-agent
|
|||||||
gt convoy show
|
gt convoy show
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Runtime Configuration
|
||||||
|
|
||||||
|
Gas Town supports multiple AI coding runtimes. Per-rig runtime settings are in `settings/config.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"provider": "codex",
|
||||||
|
"command": "codex",
|
||||||
|
"args": [],
|
||||||
|
"prompt_mode": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Claude uses hooks in `.claude/settings.json` for mail injection and startup.
|
||||||
|
- For Codex, set `project_doc_fallback_filenames = ["CLAUDE.md"]` in
|
||||||
|
`~/.codex/config.toml` so role instructions are picked up.
|
||||||
|
- For runtimes without hooks (e.g., Codex), Gas Town sends a startup fallback
|
||||||
|
after the session is ready: `gt prime`, optional `gt mail check --inject`
|
||||||
|
for autonomous roles, and `gt nudge deacon session-started`.
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
### Workspace Management
|
### Workspace Management
|
||||||
@@ -314,6 +351,10 @@ bd mol pour <formula> # Create trackable instance
|
|||||||
bd mol list # List active instances
|
bd mol list # List active instances
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Cooking Formulas
|
||||||
|
|
||||||
|
Gas Town includes built-in formulas for common workflows. See `.beads/formulas/` for available recipes.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
Gas Town includes a web dashboard for monitoring:
|
Gas Town includes a web dashboard for monitoring:
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ Complete setup guide for Gas Town multi-agent orchestrator.
|
|||||||
| Tool | Version | Check | Install |
|
| Tool | Version | Check | Install |
|
||||||
|------|---------|-------|---------|
|
|------|---------|-------|---------|
|
||||||
| **tmux** | 3.0+ | `tmux -V` | See below |
|
| **tmux** | 3.0+ | `tmux -V` | See below |
|
||||||
| **Claude Code** | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
|
| **Claude Code** (default) | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
|
||||||
|
| **Codex CLI** (optional) | latest | `codex --version` | See [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) |
|
||||||
|
|
||||||
## Installing Prerequisites
|
## Installing Prerequisites
|
||||||
|
|
||||||
@@ -159,16 +160,17 @@ Gas Town supports two operational modes:
|
|||||||
|
|
||||||
### Minimal Mode (No Daemon)
|
### Minimal Mode (No Daemon)
|
||||||
|
|
||||||
Run individual Claude Code instances manually. Gas Town only tracks state.
|
Run individual runtime instances manually. Gas Town only tracks state.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create and assign work
|
# Create and assign work
|
||||||
gt convoy create "Fix bugs" issue-123
|
gt convoy create "Fix bugs" issue-123
|
||||||
gt sling issue-123 myproject
|
gt sling issue-123 myproject
|
||||||
|
|
||||||
# Run Claude manually
|
# Run runtime manually
|
||||||
cd ~/gt/myproject/polecats/<worker>
|
cd ~/gt/myproject/polecats/<worker>
|
||||||
claude --resume
|
claude --resume # Claude Code
|
||||||
|
# or: codex # Codex CLI
|
||||||
|
|
||||||
# Check progress
|
# Check progress
|
||||||
gt convoy list
|
gt convoy list
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common errors
|
// Common errors
|
||||||
@@ -766,7 +768,7 @@ func (b *Beads) Update(id string, opts UpdateOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close closes one or more issues.
|
// Close closes one or more issues.
|
||||||
// If CLAUDE_SESSION_ID is set in the environment, it is passed to bd close
|
// If a runtime session ID is set in the environment, it is passed to bd close
|
||||||
// for work attribution tracking (see decision 009-session-events-architecture.md).
|
// for work attribution tracking (see decision 009-session-events-architecture.md).
|
||||||
func (b *Beads) Close(ids ...string) error {
|
func (b *Beads) Close(ids ...string) error {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
@@ -776,7 +778,7 @@ func (b *Beads) Close(ids ...string) error {
|
|||||||
args := append([]string{"close"}, ids...)
|
args := append([]string{"close"}, ids...)
|
||||||
|
|
||||||
// Pass session ID for work attribution if available
|
// Pass session ID for work attribution if available
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
args = append(args, "--session="+sessionID)
|
args = append(args, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,7 +787,7 @@ func (b *Beads) Close(ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CloseWithReason closes one or more issues with a reason.
|
// CloseWithReason closes one or more issues with a reason.
|
||||||
// If CLAUDE_SESSION_ID is set in the environment, it is passed to bd close
|
// If a runtime session ID is set in the environment, it is passed to bd close
|
||||||
// for work attribution tracking (see decision 009-session-events-architecture.md).
|
// for work attribution tracking (see decision 009-session-events-architecture.md).
|
||||||
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
@@ -796,7 +798,7 @@ func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
|||||||
args = append(args, "--reason="+reason)
|
args = append(args, "--reason="+reason)
|
||||||
|
|
||||||
// Pass session ID for work attribution if available
|
// Pass session ID for work attribution if available
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
args = append(args, "--session="+sessionID)
|
args = append(args, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Filename is the checkpoint file name within the polecat directory.
|
// Filename is the checkpoint file name within the polecat directory.
|
||||||
@@ -84,7 +86,7 @@ func Write(polecatDir string, cp *Checkpoint) error {
|
|||||||
|
|
||||||
// Set session ID from environment if available
|
// Set session ID from environment if available
|
||||||
if cp.SessionID == "" {
|
if cp.SessionID == "" {
|
||||||
cp.SessionID = os.Getenv("CLAUDE_SESSION_ID")
|
cp.SessionID = runtime.SessionIDFromEnv()
|
||||||
if cp.SessionID == "" {
|
if cp.SessionID == "" {
|
||||||
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
|
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,17 +38,24 @@ func RoleTypeFor(role string) RoleType {
|
|||||||
// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory,
|
// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory,
|
||||||
// so our settings.json is the only one Claude Code sees.
|
// so our settings.json is the only one Claude Code sees.
|
||||||
func EnsureSettings(workDir string, roleType RoleType) error {
|
func EnsureSettings(workDir string, roleType RoleType) error {
|
||||||
claudeDir := filepath.Join(workDir, ".claude")
|
return EnsureSettingsAt(workDir, roleType, ".claude", "settings.json")
|
||||||
settingsPath := filepath.Join(claudeDir, "settings.json")
|
}
|
||||||
|
|
||||||
|
// EnsureSettingsAt ensures a settings file exists at a custom directory/file.
|
||||||
|
// If the file doesn't exist, it copies the appropriate template based on role type.
|
||||||
|
// If the file already exists, it's left unchanged.
|
||||||
|
func EnsureSettingsAt(workDir string, roleType RoleType, settingsDir, settingsFile string) error {
|
||||||
|
claudeDir := filepath.Join(workDir, settingsDir)
|
||||||
|
settingsPath := filepath.Join(claudeDir, settingsFile)
|
||||||
|
|
||||||
// If settings already exist, don't overwrite
|
// If settings already exist, don't overwrite
|
||||||
if _, err := os.Stat(settingsPath); err == nil {
|
if _, err := os.Stat(settingsPath); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .claude directory if needed
|
// Create settings directory if needed
|
||||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||||
return fmt.Errorf("creating .claude directory: %w", err)
|
return fmt.Errorf("creating settings directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select template based on role type
|
// Select template based on role type
|
||||||
@@ -78,3 +85,8 @@ func EnsureSettings(workDir string, roleType RoleType) error {
|
|||||||
func EnsureSettingsForRole(workDir, role string) error {
|
func EnsureSettingsForRole(workDir, role string) error {
|
||||||
return EnsureSettings(workDir, RoleTypeFor(role))
|
return EnsureSettings(workDir, RoleTypeFor(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureSettingsForRoleAt is a convenience function that combines RoleTypeFor and EnsureSettingsAt.
|
||||||
|
func EnsureSettingsForRoleAt(workDir, role, settingsDir, settingsFile string) error {
|
||||||
|
return EnsureSettingsAt(workDir, RoleTypeFor(role), settingsDir, settingsFile)
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve account for Claude config
|
// Resolve account for runtime config
|
||||||
townRoot, err := workspace.FindFromCwd()
|
townRoot, err := workspace.FindFromCwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("finding town root: %w", err)
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
@@ -87,6 +87,8 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Using account: %s\n", accountHandle)
|
fmt.Printf("Using account: %s\n", accountHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
sessionID := crewSessionName(r.Name, name)
|
sessionID := crewSessionName(r.Name, name)
|
||||||
@@ -95,15 +97,15 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before creating a new session, check if there's already a Claude session
|
// Before creating a new session, check if there's already a runtime session
|
||||||
// running in this crew's directory (might have been started manually or via
|
// running in this crew's directory (might have been started manually or via
|
||||||
// a different mechanism)
|
// a different mechanism)
|
||||||
if !hasSession {
|
if !hasSession {
|
||||||
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, true)
|
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, runtimeConfig.Tmux.ProcessNames)
|
||||||
if err == nil && len(existingSessions) > 0 {
|
if err == nil && len(existingSessions) > 0 {
|
||||||
// Found an existing session with an agent running in this directory
|
// Found an existing session with runtime running in this directory
|
||||||
existingSession := existingSessions[0]
|
existingSession := existingSessions[0]
|
||||||
fmt.Printf("%s Found existing agent session '%s' in crew directory\n",
|
fmt.Printf("%s Found existing runtime session '%s' in crew directory\n",
|
||||||
style.Warning.Render("⚠"),
|
style.Warning.Render("⚠"),
|
||||||
existingSession)
|
existingSession)
|
||||||
fmt.Printf(" Attaching to existing session instead of creating a new one\n")
|
fmt.Printf(" Attaching to existing session instead of creating a new one\n")
|
||||||
@@ -137,9 +139,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||||
|
|
||||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
// Set runtime config dir for account selection (non-fatal)
|
||||||
if claudeConfigDir != "" {
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||||
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
|
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
||||||
@@ -158,31 +160,35 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("getting pane ID: %w", err)
|
return fmt.Errorf("getting pane ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use respawn-pane to replace shell with Claude directly
|
// Use respawn-pane to replace shell with runtime directly
|
||||||
// This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell)
|
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
|
||||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
// Pass "gt prime" as initial prompt if supported
|
||||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
}
|
}
|
||||||
|
// Prepend config dir env if available
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||||
|
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||||
|
}
|
||||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||||
return fmt.Errorf("starting claude: %w", err)
|
return fmt.Errorf("starting runtime: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Created session for %s/%s\n",
|
fmt.Printf("%s Created session for %s/%s\n",
|
||||||
style.Bold.Render("✓"), r.Name, name)
|
style.Bold.Render("✓"), r.Name, name)
|
||||||
} else {
|
} else {
|
||||||
// Session exists - check if Claude is still running
|
// Session exists - check if runtime is still running
|
||||||
// Uses both pane command check and UI marker detection to avoid
|
// Uses both pane command check and UI marker detection to avoid
|
||||||
// restarting when user is in a subshell spawned from Claude
|
// restarting when user is in a subshell spawned from the runtime
|
||||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolving agent: %w", err)
|
return fmt.Errorf("resolving agent: %w", err)
|
||||||
}
|
}
|
||||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||||
// Claude has exited, restart it using respawn-pane
|
// Runtime has exited, restart it using respawn-pane
|
||||||
fmt.Printf("Claude exited, restarting...\n")
|
fmt.Printf("Runtime exited, restarting...\n")
|
||||||
|
|
||||||
// Get pane ID for respawn
|
// Get pane ID for respawn
|
||||||
paneID, err := t.GetPaneID(sessionID)
|
paneID, err := t.GetPaneID(sessionID)
|
||||||
@@ -190,15 +196,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("getting pane ID: %w", err)
|
return fmt.Errorf("getting pane ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use respawn-pane to replace shell with Claude directly
|
// Use respawn-pane to replace shell with runtime directly
|
||||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
// Pass "gt prime" as initial prompt if supported
|
||||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
}
|
}
|
||||||
|
// Prepend config dir env if available
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||||
|
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||||
|
}
|
||||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||||
return fmt.Errorf("restarting claude: %w", err)
|
return fmt.Errorf("restarting runtime: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isShellCommand checks if the command is a shell (meaning Claude has exited).
|
// isShellCommand checks if the command is a shell (meaning the runtime has exited).
|
||||||
func isShellCommand(cmd string) bool {
|
func isShellCommand(cmd string) bool {
|
||||||
shells := constants.SupportedShells
|
shells := constants.SupportedShells
|
||||||
for _, shell := range shells {
|
for _, shell := range shells {
|
||||||
@@ -170,6 +170,29 @@ func execAgent(cfg *config.RuntimeConfig, prompt string) error {
|
|||||||
return syscall.Exec(agentPath, args, os.Environ())
|
return syscall.Exec(agentPath, args, os.Environ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// execRuntime execs the runtime CLI, replacing the current process.
|
||||||
|
// Used when we're already in the target session and just need to start the runtime.
|
||||||
|
// If prompt is provided, it's passed according to the runtime's prompt mode.
|
||||||
|
func execRuntime(prompt, rigPath, configDir string) error {
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||||
|
args := runtimeConfig.BuildArgsWithPrompt(prompt)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("runtime command not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath, err := exec.LookPath(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("runtime command not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := os.Environ()
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && configDir != "" {
|
||||||
|
env = append(env, fmt.Sprintf("%s=%s", runtimeConfig.Session.ConfigDirEnv, configDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
return syscall.Exec(binPath, args, env)
|
||||||
|
}
|
||||||
|
|
||||||
// isInTmuxSession checks if we're currently inside the target tmux session.
|
// isInTmuxSession checks if we're currently inside the target tmux session.
|
||||||
func isInTmuxSession(targetSession string) bool {
|
func isInTmuxSession(targetSession string) bool {
|
||||||
// TMUX env var format: /tmp/tmux-501/default,12345,0
|
// TMUX env var format: /tmp/tmux-501/default,12345,0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/townlog"
|
"github.com/steveyegge/gastown/internal/townlog"
|
||||||
@@ -163,7 +164,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
|||||||
} else {
|
} else {
|
||||||
// Default: CLOSE the agent bead (preserves CV history)
|
// Default: CLOSE the agent bead (preserves CV history)
|
||||||
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
|
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
@@ -236,9 +237,9 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Use manager's Start() with refresh options
|
// Use manager's Start() with refresh options
|
||||||
err = crewMgr.Start(name, crew.StartOptions{
|
err = crewMgr.Start(name, crew.StartOptions{
|
||||||
KillExisting: true, // Kill old session if running
|
KillExisting: true, // Kill old session if running
|
||||||
Topic: "refresh", // Startup nudge topic
|
Topic: "refresh", // Startup nudge topic
|
||||||
Interactive: true, // No --dangerously-skip-permissions
|
Interactive: true, // No --dangerously-skip-permissions
|
||||||
AgentOverride: crewAgentOverride,
|
AgentOverride: crewAgentOverride,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -347,8 +348,8 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
|
|||||||
// Use manager's Start() with restart options
|
// Use manager's Start() with restart options
|
||||||
// Start() will create workspace if needed (idempotent)
|
// Start() will create workspace if needed (idempotent)
|
||||||
err = crewMgr.Start(name, crew.StartOptions{
|
err = crewMgr.Start(name, crew.StartOptions{
|
||||||
KillExisting: true, // Kill old session if running
|
KillExisting: true, // Kill old session if running
|
||||||
Topic: "restart", // Startup nudge topic
|
Topic: "restart", // Startup nudge topic
|
||||||
AgentOverride: crewAgentOverride,
|
AgentOverride: crewAgentOverride,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -427,8 +428,8 @@ func runCrewRestartAll() error {
|
|||||||
|
|
||||||
// Use manager's Start() with restart options
|
// Use manager's Start() with restart options
|
||||||
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
|
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
|
||||||
KillExisting: true, // Kill old session if running
|
KillExisting: true, // Kill old session if running
|
||||||
Topic: "restart", // Startup nudge topic
|
Topic: "restart", // Startup nudge topic
|
||||||
AgentOverride: crewAgentOverride,
|
AgentOverride: crewAgentOverride,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/deacon"
|
"github.com/steveyegge/gastown/internal/deacon"
|
||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
@@ -112,7 +113,7 @@ var deaconTriggerPendingCmd = &cobra.Command{
|
|||||||
|
|
||||||
⚠️ BOOTSTRAP MODE ONLY - Uses regex detection (ZFC violation acceptable).
|
⚠️ BOOTSTRAP MODE ONLY - Uses regex detection (ZFC violation acceptable).
|
||||||
|
|
||||||
This command uses WaitForClaudeReady (regex) to detect when Claude is ready.
|
This command uses WaitForRuntimeReady (regex) to detect when the runtime is ready.
|
||||||
This is appropriate for daemon bootstrap when no AI is available.
|
This is appropriate for daemon bootstrap when no AI is available.
|
||||||
|
|
||||||
In steady-state, the Deacon should use AI-based observation instead:
|
In steady-state, the Deacon should use AI-based observation instead:
|
||||||
@@ -383,6 +384,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
|||||||
}
|
}
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig("")
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
Recipient: "deacon",
|
Recipient: "deacon",
|
||||||
|
|||||||
@@ -357,9 +357,13 @@ func buildRestartCommand(sessionName string) (string, error) {
|
|||||||
// Build environment exports - role vars first, then Claude vars
|
// Build environment exports - role vars first, then Claude vars
|
||||||
var exports []string
|
var exports []string
|
||||||
if gtRole != "" {
|
if gtRole != "" {
|
||||||
exports = append(exports, fmt.Sprintf("GT_ROLE=%s", gtRole))
|
runtimeConfig := config.LoadRuntimeConfig("")
|
||||||
exports = append(exports, fmt.Sprintf("BD_ACTOR=%s", gtRole))
|
exports = append(exports, "GT_ROLE="+gtRole)
|
||||||
exports = append(exports, fmt.Sprintf("GIT_AUTHOR_NAME=%s", gtRole))
|
exports = append(exports, "BD_ACTOR="+gtRole)
|
||||||
|
exports = append(exports, "GIT_AUTHOR_NAME="+gtRole)
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
||||||
|
exports = append(exports, "GT_SESSION_ID_ENV="+runtimeConfig.Session.SessionIDEnv)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Claude-related env vars from current environment
|
// Add Claude-related env vars from current environment
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ func runHook(_ *cobra.Command, args []string) error {
|
|||||||
// Close completed molecule bead (use bd close --force for pinned)
|
// Close completed molecule bead (use bd close --force for pinned)
|
||||||
closeArgs := []string{"close", existing.ID, "--force",
|
closeArgs := []string{"close", existing.ID, "--force",
|
||||||
"--reason=Auto-replaced by gt hook (molecule complete)"}
|
"--reason=Auto-replaced by gt hook (molecule complete)"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
)
|
)
|
||||||
@@ -89,7 +90,6 @@ Examples:
|
|||||||
RunE: runPolecatRemove,
|
RunE: runPolecatRemove,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var polecatSyncCmd = &cobra.Command{
|
var polecatSyncCmd = &cobra.Command{
|
||||||
Use: "sync <rig>/<polecat>",
|
Use: "sync <rig>/<polecat>",
|
||||||
Short: "Sync beads for a polecat",
|
Short: "Sync beads for a polecat",
|
||||||
@@ -129,15 +129,15 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
polecatSyncAll bool
|
polecatSyncAll bool
|
||||||
polecatSyncFromMain bool
|
polecatSyncFromMain bool
|
||||||
polecatStatusJSON bool
|
polecatStatusJSON bool
|
||||||
polecatGitStateJSON bool
|
polecatGitStateJSON bool
|
||||||
polecatGCDryRun bool
|
polecatGCDryRun bool
|
||||||
polecatNukeAll bool
|
polecatNukeAll bool
|
||||||
polecatNukeDryRun bool
|
polecatNukeDryRun bool
|
||||||
polecatNukeForce bool
|
polecatNukeForce bool
|
||||||
polecatCheckRecoveryJSON bool
|
polecatCheckRecoveryJSON bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var polecatGCCmd = &cobra.Command{
|
var polecatGCCmd = &cobra.Command{
|
||||||
@@ -975,7 +975,7 @@ type RecoveryStatus struct {
|
|||||||
NeedsRecovery bool `json:"needs_recovery"`
|
NeedsRecovery bool `json:"needs_recovery"`
|
||||||
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
|
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
|
||||||
Branch string `json:"branch,omitempty"`
|
Branch string `json:"branch,omitempty"`
|
||||||
Issue string `json:"issue,omitempty"`
|
Issue string `json:"issue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
|
func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
|
||||||
@@ -1477,7 +1477,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
|||||||
// Step 5: Close agent bead (if exists)
|
// Step 5: Close agent bead (if exists)
|
||||||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||||||
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve account for Claude config
|
// Resolve account for runtime config
|
||||||
accountsPath := constants.MayorAccountsPath(townRoot)
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
|
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,7 +158,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
|||||||
if !running {
|
if !running {
|
||||||
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
||||||
startOpts := polecat.SessionStartOptions{
|
startOpts := polecat.SessionStartOptions{
|
||||||
ClaudeConfigDir: claudeConfigDir,
|
RuntimeConfigDir: claudeConfigDir,
|
||||||
}
|
}
|
||||||
if opts.Agent != "" {
|
if opts.Agent != "" {
|
||||||
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, r.Path, "", opts.Agent)
|
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, r.Path, "", opts.Agent)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/lock"
|
"github.com/steveyegge/gastown/internal/lock"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
@@ -1499,22 +1500,17 @@ func outputSessionMetadata(ctx RoleContext) {
|
|||||||
// resolveSessionIDForPrime finds the session ID from available sources.
|
// resolveSessionIDForPrime finds the session ID from available sources.
|
||||||
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
|
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
|
||||||
func resolveSessionIDForPrime(actor string) string {
|
func resolveSessionIDForPrime(actor string) string {
|
||||||
// 1. GT_SESSION_ID (new canonical)
|
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
|
||||||
if id := os.Getenv("GT_SESSION_ID"); id != "" {
|
if id := runtime.SessionIDFromEnv(); id != "" {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. CLAUDE_SESSION_ID (legacy/Claude Code)
|
// 2. Persisted session file (from gt prime --hook)
|
||||||
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Persisted session file (from gt prime --hook)
|
|
||||||
if id := ReadPersistedSessionID(); id != "" {
|
if id := ReadPersistedSessionID(); id != "" {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fallback to generated identifier
|
// 3. Fallback to generated identifier
|
||||||
return fmt.Sprintf("%s-%d", actor, os.Getpid())
|
return fmt.Sprintf("%s-%d", actor, os.Getpid())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
"github.com/steveyegge/gastown/internal/refinery"
|
"github.com/steveyegge/gastown/internal/refinery"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/witness"
|
"github.com/steveyegge/gastown/internal/witness"
|
||||||
@@ -317,6 +319,86 @@ func discoverAllRigs(townRoot string) ([]*rig.Rig, error) {
|
|||||||
return rigMgr.DiscoverRigs()
|
return rigMgr.DiscoverRigs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureRefinerySession creates a refinery tmux session if it doesn't exist.
|
||||||
|
// Returns true if a new session was created, false if it already existed.
|
||||||
|
func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
|
||||||
|
t := tmux.NewTmux()
|
||||||
|
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
||||||
|
|
||||||
|
// Check if session already exists
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("checking session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if running {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working directory is the refinery's rig clone
|
||||||
|
refineryRigDir := filepath.Join(r.Path, "refinery", "rig")
|
||||||
|
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
|
||||||
|
// Fall back to rig path if refinery/rig doesn't exist
|
||||||
|
refineryRigDir = r.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure runtime settings exist (autonomous role needs mail in SessionStart)
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||||
|
if err := runtime.EnsureSettingsForRole(refineryRigDir, "refinery", runtimeConfig); err != nil {
|
||||||
|
return false, fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new tmux session
|
||||||
|
if err := t.NewSession(sessionName, refineryRigDir); err != nil {
|
||||||
|
return false, fmt.Errorf("creating session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
_ = t.SetEnvironment(sessionName, "BEADS_DIR", beadsDir)
|
||||||
|
_ = t.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1")
|
||||||
|
_ = t.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", rigName))
|
||||||
|
|
||||||
|
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
|
||||||
|
theme := tmux.AssignTheme(rigName)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery")
|
||||||
|
|
||||||
|
// Launch Claude directly (no respawn loop - daemon handles restart)
|
||||||
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
|
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("refinery", bdActor, "", "")); err != nil {
|
||||||
|
return false, fmt.Errorf("sending command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Claude to start (non-fatal)
|
||||||
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionName, "refinery", runtimeConfig)
|
||||||
|
|
||||||
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
|
address := fmt.Sprintf("%s/refinery", rigName)
|
||||||
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "deacon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}) // Non-fatal
|
||||||
|
|
||||||
|
// GUPP: Gas Town Universal Propulsion Principle
|
||||||
|
// Send the propulsion nudge to trigger autonomous patrol execution.
|
||||||
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runShutdown(cmd *cobra.Command, args []string) error {
|
func runShutdown(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/swarm"
|
"github.com/steveyegge/gastown/internal/swarm"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
@@ -808,7 +809,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Close the swarm epic in beads
|
// Close the swarm epic in beads
|
||||||
closeArgs := []string{"close", swarmID, "--reason", "Swarm landed to main"}
|
closeArgs := []string{"close", swarmID, "--reason", "Swarm landed to main"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
@@ -867,7 +868,7 @@ func runSwarmCancel(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Close the swarm epic in beads with canceled reason
|
// Close the swarm epic in beads with canceled reason
|
||||||
closeArgs := []string{"close", swarmID, "--reason", "Swarm canceled"}
|
closeArgs := []string{"close", swarmID, "--reason", "Swarm canceled"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/formula"
|
"github.com/steveyegge/gastown/internal/formula"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -322,7 +323,7 @@ func runSynthesisClose(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Close the convoy
|
// Close the convoy
|
||||||
closeArgs := []string{"close", convoyID, "--reason=synthesis complete"}
|
closeArgs := []string{"close", convoyID, "--reason=synthesis complete"}
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
closeCmd := exec.Command("bd", closeArgs...)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/daemon"
|
"github.com/steveyegge/gastown/internal/daemon"
|
||||||
"github.com/steveyegge/gastown/internal/deacon"
|
"github.com/steveyegge/gastown/internal/deacon"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/mayor"
|
"github.com/steveyegge/gastown/internal/mayor"
|
||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
"github.com/steveyegge/gastown/internal/refinery"
|
"github.com/steveyegge/gastown/internal/refinery"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/witness"
|
"github.com/steveyegge/gastown/internal/witness"
|
||||||
@@ -249,6 +252,127 @@ func ensureDaemon(townRoot string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureSession starts a Claude session if not running.
|
||||||
|
func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
if err := t.NewSession(sessionName, workDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment (non-fatal: session works without these)
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_ROLE", role)
|
||||||
|
_ = t.SetEnvironment(sessionName, "BD_ACTOR", role)
|
||||||
|
|
||||||
|
// Apply theme based on role (non-fatal: theming failure doesn't affect operation)
|
||||||
|
switch role {
|
||||||
|
case "mayor":
|
||||||
|
theme := tmux.MayorTheme()
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||||
|
case "deacon":
|
||||||
|
theme := tmux.DeaconTheme()
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch runtime
|
||||||
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
|
var claudeCmd string
|
||||||
|
runtimeCmd := config.GetRuntimeCommand("")
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig("")
|
||||||
|
if role == "deacon" {
|
||||||
|
// Deacon uses respawn loop
|
||||||
|
prefix := "GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon"
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
||||||
|
prefix = prefix + " GT_SESSION_ID_ENV=" + runtimeConfig.Session.SessionIDEnv
|
||||||
|
}
|
||||||
|
claudeCmd = `export ` + prefix + ` && while true; do echo "⛪ Starting Deacon session..."; ` + runtimeCmd + `; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
||||||
|
} else {
|
||||||
|
claudeCmd = config.BuildAgentStartupCommand(role, role, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Claude to start (non-fatal)
|
||||||
|
// Note: Deacon respawn loop makes beacon tricky - Claude restarts multiple times
|
||||||
|
// For non-respawn (mayor), inject beacon
|
||||||
|
if role != "deacon" {
|
||||||
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
|
Recipient: role,
|
||||||
|
Sender: "human",
|
||||||
|
Topic: "cold-start",
|
||||||
|
}) // Non-fatal
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionName, role, runtimeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureWitness starts a witness session for a rig.
|
||||||
|
func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error {
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session in rig directory
|
||||||
|
if err := t.NewSession(sessionName, rigPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment (non-fatal: session works without these)
|
||||||
|
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 (non-fatal: theming failure doesn't affect operation)
|
||||||
|
theme := tmux.AssignTheme(rigName)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName)
|
||||||
|
|
||||||
|
// Launch runtime using runtime config
|
||||||
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||||
|
claudeCmd := config.BuildAgentStartupCommand("witness", bdActor, rigPath, "")
|
||||||
|
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Claude to start (non-fatal)
|
||||||
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionName, "witness", runtimeConfig)
|
||||||
|
|
||||||
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
|
address := fmt.Sprintf("%s/witness", rigName)
|
||||||
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "deacon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}) // Non-fatal
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// discoverRigs finds all rigs in the town.
|
// discoverRigs finds all rigs in the town.
|
||||||
func discoverRigs(townRoot string) []string {
|
func discoverRigs(townRoot string) []string {
|
||||||
var rigs []string
|
var rigs []string
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/witness"
|
"github.com/steveyegge/gastown/internal/witness"
|
||||||
@@ -262,6 +269,87 @@ func witnessSessionName(rigName string) string {
|
|||||||
return fmt.Sprintf("gt-%s-witness", rigName)
|
return fmt.Sprintf("gt-%s-witness", rigName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureWitnessSession creates a witness tmux session if it doesn't exist.
|
||||||
|
// Returns true if a new session was created, false if it already existed.
|
||||||
|
func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) {
|
||||||
|
t := tmux.NewTmux()
|
||||||
|
sessionName := witnessSessionName(rigName)
|
||||||
|
|
||||||
|
// Check if session already exists
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("checking session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if running {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working directory is the witness's rig clone (if it exists) or witness dir
|
||||||
|
// This ensures gt prime detects the Witness role correctly
|
||||||
|
witnessDir := filepath.Join(r.Path, "witness", "rig")
|
||||||
|
if _, err := os.Stat(witnessDir); os.IsNotExist(err) {
|
||||||
|
// Try witness/ without rig subdirectory
|
||||||
|
witnessDir = filepath.Join(r.Path, "witness")
|
||||||
|
if _, err := os.Stat(witnessDir); os.IsNotExist(err) {
|
||||||
|
// Fall back to rig path (shouldn't happen in normal setup)
|
||||||
|
witnessDir = r.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||||
|
if err := runtime.EnsureSettingsForRole(witnessDir, "witness", runtimeConfig); err != nil {
|
||||||
|
return false, fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new tmux session
|
||||||
|
if err := t.NewSession(sessionName, witnessDir); err != nil {
|
||||||
|
return false, fmt.Errorf("creating session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (non-fatal: theming failure doesn't affect operation)
|
||||||
|
theme := tmux.AssignTheme(rigName)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
||||||
|
|
||||||
|
// Launch Claude directly (no shell respawn loop)
|
||||||
|
// Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan
|
||||||
|
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
|
||||||
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
|
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("witness", bdActor, "", "")); err != nil {
|
||||||
|
return false, fmt.Errorf("sending command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Claude to start (non-fatal)
|
||||||
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionName, "witness", runtimeConfig)
|
||||||
|
|
||||||
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
|
address := fmt.Sprintf("%s/witness", rigName)
|
||||||
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "deacon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}) // Non-fatal
|
||||||
|
|
||||||
|
// GUPP: Gas Town Universal Propulsion Principle
|
||||||
|
// Send the propulsion nudge to trigger autonomous patrol execution.
|
||||||
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runWitnessAttach(cmd *cobra.Command, args []string) error {
|
func runWitnessAttach(cmd *cobra.Command, args []string) error {
|
||||||
rigName := ""
|
rigName := ""
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
|
|||||||
@@ -726,15 +726,7 @@ func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
|
|||||||
if settings.Runtime == nil {
|
if settings.Runtime == nil {
|
||||||
return DefaultRuntimeConfig()
|
return DefaultRuntimeConfig()
|
||||||
}
|
}
|
||||||
// Fill in defaults for empty fields
|
return normalizeRuntimeConfig(settings.Runtime)
|
||||||
rc := settings.Runtime
|
|
||||||
if rc.Command == "" {
|
|
||||||
rc.Command = "claude"
|
|
||||||
}
|
|
||||||
if rc.Args == nil {
|
|
||||||
rc.Args = []string{"--dangerously-skip-permissions"}
|
|
||||||
}
|
|
||||||
return rc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TownSettingsPath returns the path to town settings file.
|
// TownSettingsPath returns the path to town settings file.
|
||||||
@@ -1078,9 +1070,18 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy env vars to avoid mutating caller map
|
||||||
|
resolvedEnv := make(map[string]string, len(envVars)+1)
|
||||||
|
for k, v := range envVars {
|
||||||
|
resolvedEnv[k] = v
|
||||||
|
}
|
||||||
|
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
|
||||||
|
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
|
||||||
|
}
|
||||||
|
|
||||||
// Build environment export prefix
|
// Build environment export prefix
|
||||||
var exports []string
|
var exports []string
|
||||||
for k, v := range envVars {
|
for k, v := range resolvedEnv {
|
||||||
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,6 +1103,21 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrependEnv prepends export statements to a command string.
|
||||||
|
func PrependEnv(command string, envVars map[string]string) string {
|
||||||
|
if len(envVars) == 0 {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
var exports []string
|
||||||
|
for k, v := range envVars {
|
||||||
|
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(exports)
|
||||||
|
return "export " + strings.Join(exports, " ") + " && " + command
|
||||||
|
}
|
||||||
|
|
||||||
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
|
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
|
||||||
// but uses agentOverride if non-empty.
|
// but uses agentOverride if non-empty.
|
||||||
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
|
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
|
||||||
|
|||||||
@@ -778,12 +778,18 @@ func TestMessagingConfigPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestRuntimeConfigDefaults(t *testing.T) {
|
func TestRuntimeConfigDefaults(t *testing.T) {
|
||||||
rc := DefaultRuntimeConfig()
|
rc := DefaultRuntimeConfig()
|
||||||
|
if rc.Provider != "claude" {
|
||||||
|
t.Errorf("Provider = %q, want %q", rc.Provider, "claude")
|
||||||
|
}
|
||||||
if rc.Command != "claude" {
|
if rc.Command != "claude" {
|
||||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||||
}
|
}
|
||||||
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
|
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
|
||||||
t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args)
|
t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args)
|
||||||
}
|
}
|
||||||
|
if rc.Session == nil || rc.Session.SessionIDEnv != "CLAUDE_SESSION_ID" {
|
||||||
|
t.Errorf("SessionIDEnv = %q, want %q", rc.Session.SessionIDEnv, "CLAUDE_SESSION_ID")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRuntimeConfigBuildCommand(t *testing.T) {
|
func TestRuntimeConfigBuildCommand(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -220,6 +221,10 @@ type CrewConfig struct {
|
|||||||
// This allows switching between different LLM backends (claude, aider, etc.)
|
// This allows switching between different LLM backends (claude, aider, etc.)
|
||||||
// without modifying startup code.
|
// without modifying startup code.
|
||||||
type RuntimeConfig struct {
|
type RuntimeConfig struct {
|
||||||
|
// Provider selects runtime-specific defaults and integration behavior.
|
||||||
|
// Known values: "claude", "codex", "generic". Default: "claude".
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
|
||||||
// Command is the CLI command to invoke (e.g., "claude", "aider").
|
// Command is the CLI command to invoke (e.g., "claude", "aider").
|
||||||
// Default: "claude"
|
// Default: "claude"
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
@@ -232,33 +237,78 @@ type RuntimeConfig struct {
|
|||||||
// For claude, this is passed as the prompt argument.
|
// For claude, this is passed as the prompt argument.
|
||||||
// Empty by default (hooks handle context).
|
// Empty by default (hooks handle context).
|
||||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||||
|
|
||||||
|
// PromptMode controls how prompts are passed to the runtime.
|
||||||
|
// Supported values: "arg" (append prompt arg), "none" (ignore prompt).
|
||||||
|
// Default: "arg" for claude/generic, "none" for codex.
|
||||||
|
PromptMode string `json:"prompt_mode,omitempty"`
|
||||||
|
|
||||||
|
// Session config controls environment integration for runtime session IDs.
|
||||||
|
Session *RuntimeSessionConfig `json:"session,omitempty"`
|
||||||
|
|
||||||
|
// Hooks config controls runtime hook installation (if supported).
|
||||||
|
Hooks *RuntimeHooksConfig `json:"hooks,omitempty"`
|
||||||
|
|
||||||
|
// Tmux config controls process detection and readiness heuristics.
|
||||||
|
Tmux *RuntimeTmuxConfig `json:"tmux,omitempty"`
|
||||||
|
|
||||||
|
// Instructions controls the per-workspace instruction file name.
|
||||||
|
Instructions *RuntimeInstructionsConfig `json:"instructions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeSessionConfig configures how Gas Town discovers runtime session IDs.
|
||||||
|
type RuntimeSessionConfig struct {
|
||||||
|
// SessionIDEnv is the environment variable set by the runtime to identify a session.
|
||||||
|
// Default: "CLAUDE_SESSION_ID" for claude, empty for codex/generic.
|
||||||
|
SessionIDEnv string `json:"session_id_env,omitempty"`
|
||||||
|
|
||||||
|
// ConfigDirEnv is the environment variable that selects a runtime account/config dir.
|
||||||
|
// Default: "CLAUDE_CONFIG_DIR" for claude, empty for codex/generic.
|
||||||
|
ConfigDirEnv string `json:"config_dir_env,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeHooksConfig configures runtime hook installation.
|
||||||
|
type RuntimeHooksConfig struct {
|
||||||
|
// Provider controls which hook templates to install: "claude" or "none".
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
|
||||||
|
// Dir is the settings directory (e.g., ".claude").
|
||||||
|
Dir string `json:"dir,omitempty"`
|
||||||
|
|
||||||
|
// SettingsFile is the settings file name (e.g., "settings.json").
|
||||||
|
SettingsFile string `json:"settings_file,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeTmuxConfig controls tmux heuristics for detecting runtime readiness.
|
||||||
|
type RuntimeTmuxConfig struct {
|
||||||
|
// ProcessNames are tmux pane commands that indicate the runtime is running.
|
||||||
|
ProcessNames []string `json:"process_names,omitempty"`
|
||||||
|
|
||||||
|
// ReadyPromptPrefix is the prompt prefix to detect readiness (e.g., "> ").
|
||||||
|
ReadyPromptPrefix string `json:"ready_prompt_prefix,omitempty"`
|
||||||
|
|
||||||
|
// ReadyDelayMs is a fixed delay used when prompt detection is unavailable.
|
||||||
|
ReadyDelayMs int `json:"ready_delay_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeInstructionsConfig controls the name of the role instruction file.
|
||||||
|
type RuntimeInstructionsConfig struct {
|
||||||
|
// File is the instruction filename (e.g., "CLAUDE.md", "AGENTS.md").
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRuntimeConfig returns a RuntimeConfig with sensible defaults.
|
// DefaultRuntimeConfig returns a RuntimeConfig with sensible defaults.
|
||||||
func DefaultRuntimeConfig() *RuntimeConfig {
|
func DefaultRuntimeConfig() *RuntimeConfig {
|
||||||
return &RuntimeConfig{
|
return normalizeRuntimeConfig(&RuntimeConfig{Provider: "claude"})
|
||||||
Command: "claude",
|
|
||||||
Args: []string{"--dangerously-skip-permissions"},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCommand returns the full command line string.
|
// BuildCommand returns the full command line string.
|
||||||
// For use with tmux SendKeys.
|
// For use with tmux SendKeys.
|
||||||
func (rc *RuntimeConfig) BuildCommand() string {
|
func (rc *RuntimeConfig) BuildCommand() string {
|
||||||
if rc == nil {
|
resolved := normalizeRuntimeConfig(rc)
|
||||||
return DefaultRuntimeConfig().BuildCommand()
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := rc.Command
|
cmd := resolved.Command
|
||||||
if cmd == "" {
|
args := resolved.Args
|
||||||
cmd = "claude"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build args
|
|
||||||
args := rc.Args
|
|
||||||
if args == nil {
|
|
||||||
args = []string{"--dangerously-skip-permissions"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine command and args
|
// Combine command and args
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -271,15 +321,16 @@ func (rc *RuntimeConfig) BuildCommand() string {
|
|||||||
// If the config has an InitialPrompt, it's appended as a quoted argument.
|
// If the config has an InitialPrompt, it's appended as a quoted argument.
|
||||||
// If prompt is provided, it overrides the config's InitialPrompt.
|
// If prompt is provided, it overrides the config's InitialPrompt.
|
||||||
func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
||||||
base := rc.BuildCommand()
|
resolved := normalizeRuntimeConfig(rc)
|
||||||
|
base := resolved.BuildCommand()
|
||||||
|
|
||||||
// Use provided prompt or fall back to config
|
// Use provided prompt or fall back to config
|
||||||
p := prompt
|
p := prompt
|
||||||
if p == "" && rc != nil {
|
if p == "" {
|
||||||
p = rc.InitialPrompt
|
p = resolved.InitialPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
if p == "" {
|
if p == "" || resolved.PromptMode == "none" {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +338,197 @@ func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
|||||||
return base + " " + quoteForShell(p)
|
return base + " " + quoteForShell(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildArgsWithPrompt returns the runtime command and args suitable for exec.
|
||||||
|
func (rc *RuntimeConfig) BuildArgsWithPrompt(prompt string) []string {
|
||||||
|
resolved := normalizeRuntimeConfig(rc)
|
||||||
|
args := append([]string{resolved.Command}, resolved.Args...)
|
||||||
|
|
||||||
|
p := prompt
|
||||||
|
if p == "" {
|
||||||
|
p = resolved.InitialPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if p != "" && resolved.PromptMode != "none" {
|
||||||
|
args = append(args, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRuntimeConfig(rc *RuntimeConfig) *RuntimeConfig {
|
||||||
|
if rc == nil {
|
||||||
|
rc = &RuntimeConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Provider == "" {
|
||||||
|
rc.Provider = "claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Command == "" {
|
||||||
|
rc.Command = defaultRuntimeCommand(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Args == nil {
|
||||||
|
rc.Args = defaultRuntimeArgs(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.PromptMode == "" {
|
||||||
|
rc.PromptMode = defaultPromptMode(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Session == nil {
|
||||||
|
rc.Session = &RuntimeSessionConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Session.SessionIDEnv == "" {
|
||||||
|
rc.Session.SessionIDEnv = defaultSessionIDEnv(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Session.ConfigDirEnv == "" {
|
||||||
|
rc.Session.ConfigDirEnv = defaultConfigDirEnv(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Hooks == nil {
|
||||||
|
rc.Hooks = &RuntimeHooksConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Hooks.Provider == "" {
|
||||||
|
rc.Hooks.Provider = defaultHooksProvider(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Hooks.Dir == "" {
|
||||||
|
rc.Hooks.Dir = defaultHooksDir(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Hooks.SettingsFile == "" {
|
||||||
|
rc.Hooks.SettingsFile = defaultHooksFile(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Tmux == nil {
|
||||||
|
rc.Tmux = &RuntimeTmuxConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Tmux.ProcessNames == nil {
|
||||||
|
rc.Tmux.ProcessNames = defaultProcessNames(rc.Provider, rc.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Tmux.ReadyPromptPrefix == "" {
|
||||||
|
rc.Tmux.ReadyPromptPrefix = defaultReadyPromptPrefix(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Tmux.ReadyDelayMs == 0 {
|
||||||
|
rc.Tmux.ReadyDelayMs = defaultReadyDelayMs(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Instructions == nil {
|
||||||
|
rc.Instructions = &RuntimeInstructionsConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Instructions.File == "" {
|
||||||
|
rc.Instructions.File = defaultInstructionsFile(rc.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRuntimeCommand(provider string) string {
|
||||||
|
switch provider {
|
||||||
|
case "codex":
|
||||||
|
return "codex"
|
||||||
|
case "generic":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "claude"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRuntimeArgs(provider string) []string {
|
||||||
|
switch provider {
|
||||||
|
case "claude":
|
||||||
|
return []string{"--dangerously-skip-permissions"}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPromptMode(provider string) string {
|
||||||
|
switch provider {
|
||||||
|
case "codex":
|
||||||
|
return "none"
|
||||||
|
default:
|
||||||
|
return "arg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSessionIDEnv(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return "CLAUDE_SESSION_ID"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigDirEnv(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return "CLAUDE_CONFIG_DIR"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultHooksProvider(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return "claude"
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultHooksDir(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return ".claude"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultHooksFile(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return "settings.json"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProcessNames(provider, command string) []string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return []string{"node"}
|
||||||
|
}
|
||||||
|
if command != "" {
|
||||||
|
return []string{filepath.Base(command)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultReadyPromptPrefix(provider string) string {
|
||||||
|
if provider == "claude" {
|
||||||
|
return "> "
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultReadyDelayMs(provider string) int {
|
||||||
|
if provider == "claude" {
|
||||||
|
return 10000
|
||||||
|
}
|
||||||
|
if provider == "codex" {
|
||||||
|
return 3000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultInstructionsFile(provider string) string {
|
||||||
|
if provider == "codex" {
|
||||||
|
return "AGENTS.md"
|
||||||
|
}
|
||||||
|
return "CLAUDE.md"
|
||||||
|
}
|
||||||
|
|
||||||
// quoteForShell quotes a string for safe shell usage.
|
// quoteForShell quotes a string for safe shell usage.
|
||||||
func quoteForShell(s string) string {
|
func quoteForShell(s string) string {
|
||||||
// Simple quoting: wrap in double quotes, escape internal quotes
|
// Simple quoting: wrap in double quotes, escape internal quotes
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (d *Daemon) heartbeat(state *State) {
|
|||||||
|
|
||||||
// 3. Trigger pending polecat spawns (bootstrap mode - ZFC violation acceptable)
|
// 3. Trigger pending polecat spawns (bootstrap mode - ZFC violation acceptable)
|
||||||
// This ensures polecats get nudged even when Deacon isn't in a patrol cycle.
|
// This ensures polecats get nudged even when Deacon isn't in a patrol cycle.
|
||||||
// Uses regex-based WaitForClaudeReady, which is acceptable for daemon bootstrap.
|
// Uses regex-based WaitForRuntimeReady, which is acceptable for daemon bootstrap.
|
||||||
d.triggerPendingSpawns()
|
d.triggerPendingSpawns()
|
||||||
|
|
||||||
// 4. Process lifecycle requests
|
// 4. Process lifecycle requests
|
||||||
@@ -528,7 +528,7 @@ func (d *Daemon) isRigOperational(rigName string) (bool, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// triggerPendingSpawns polls pending polecat spawns and triggers those that are ready.
|
// triggerPendingSpawns polls pending polecat spawns and triggers those that are ready.
|
||||||
// This is bootstrap mode - uses regex-based WaitForClaudeReady which is acceptable
|
// This is bootstrap mode - uses regex-based WaitForRuntimeReady which is acceptable
|
||||||
// for daemon operations when no AI agent is guaranteed to be running.
|
// for daemon operations when no AI agent is guaranteed to be running.
|
||||||
// The timeout is short (2s) to avoid blocking the heartbeat.
|
// The timeout is short (2s) to avoid blocking the heartbeat.
|
||||||
func (d *Daemon) triggerPendingSpawns() {
|
func (d *Daemon) triggerPendingSpawns() {
|
||||||
@@ -547,7 +547,7 @@ func (d *Daemon) triggerPendingSpawns() {
|
|||||||
|
|
||||||
d.logger.Printf("Found %d pending spawn(s), attempting to trigger...", len(pending))
|
d.logger.Printf("Found %d pending spawn(s), attempting to trigger...", len(pending))
|
||||||
|
|
||||||
// Trigger pending spawns (uses WaitForClaudeReady with short timeout)
|
// Trigger pending spawns (uses WaitForRuntimeReady with short timeout)
|
||||||
results, err := polecat.TriggerPendingSpawns(d.config.TownRoot, triggerTimeout)
|
results, err := polecat.TriggerPendingSpawns(d.config.TownRoot, triggerTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Printf("Error triggering spawns: %v", err)
|
d.logger.Printf("Error triggering spawns: %v", err)
|
||||||
|
|||||||
@@ -466,6 +466,10 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
|
|||||||
|
|
||||||
// Default command for all agents - use runtime config
|
// Default command for all agents - use runtime config
|
||||||
defaultCmd := "exec " + config.GetRuntimeCommand(rigPath)
|
defaultCmd := "exec " + config.GetRuntimeCommand(rigPath)
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
||||||
|
defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": runtimeConfig.Session.SessionIDEnv})
|
||||||
|
}
|
||||||
|
|
||||||
// Polecats need environment variables set in the command
|
// Polecats need environment variables set in the command
|
||||||
if parsed.RoleType == "polecat" {
|
if parsed.RoleType == "polecat" {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (c *OrphanSessionCheck) isValidSession(sess string, validRigs []string, may
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrphanProcessCheck detects orphaned Claude/claude-code processes
|
// OrphanProcessCheck detects orphaned runtime processes
|
||||||
// that are not associated with a Gas Town tmux session.
|
// that are not associated with a Gas Town tmux session.
|
||||||
type OrphanProcessCheck struct {
|
type OrphanProcessCheck struct {
|
||||||
FixableCheck
|
FixableCheck
|
||||||
@@ -238,13 +238,13 @@ func NewOrphanProcessCheck() *OrphanProcessCheck {
|
|||||||
FixableCheck: FixableCheck{
|
FixableCheck: FixableCheck{
|
||||||
BaseCheck: BaseCheck{
|
BaseCheck: BaseCheck{
|
||||||
CheckName: "orphan-processes",
|
CheckName: "orphan-processes",
|
||||||
CheckDescription: "Detect orphaned Claude processes",
|
CheckDescription: "Detect orphaned runtime processes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run checks for orphaned Claude processes.
|
// Run checks for orphaned runtime processes.
|
||||||
func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
// Get list of tmux session PIDs
|
// Get list of tmux session PIDs
|
||||||
tmuxPIDs, err := c.getTmuxSessionPIDs()
|
tmuxPIDs, err := c.getTmuxSessionPIDs()
|
||||||
@@ -257,30 +257,30 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find Claude processes
|
// Find runtime processes
|
||||||
claudeProcs, err := c.findClaudeProcesses()
|
runtimeProcs, err := c.findRuntimeProcesses()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Could not list Claude processes",
|
Message: "Could not list runtime processes",
|
||||||
Details: []string{err.Error()},
|
Details: []string{err.Error()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(claudeProcs) == 0 {
|
if len(runtimeProcs) == 0 {
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
Message: "No Claude processes found",
|
Message: "No runtime processes found",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which Claude processes are orphaned
|
// Check which runtime processes are orphaned
|
||||||
var orphans []processInfo
|
var orphans []processInfo
|
||||||
var validCount int
|
var validCount int
|
||||||
|
|
||||||
for _, proc := range claudeProcs {
|
for _, proc := range runtimeProcs {
|
||||||
if c.isOrphanProcess(proc, tmuxPIDs) {
|
if c.isOrphanProcess(proc, tmuxPIDs) {
|
||||||
orphans = append(orphans, proc)
|
orphans = append(orphans, proc)
|
||||||
} else {
|
} else {
|
||||||
@@ -298,7 +298,7 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
Message: fmt.Sprintf("All %d Claude processes have valid parents", validCount),
|
Message: fmt.Sprintf("All %d runtime processes have valid parents", validCount),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: fmt.Sprintf("Found %d orphaned Claude process(es)", len(orphans)),
|
Message: fmt.Sprintf("Found %d orphaned runtime process(es)", len(orphans)),
|
||||||
Details: details,
|
Details: details,
|
||||||
FixHint: "Run 'gt doctor --fix' to kill orphaned processes",
|
FixHint: "Run 'gt doctor --fix' to kill orphaned processes",
|
||||||
}
|
}
|
||||||
@@ -462,21 +462,20 @@ func (c *OrphanProcessCheck) getTmuxSessionPIDs() (map[int]bool, error) { //noli
|
|||||||
return pids, nil
|
return pids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findClaudeProcesses finds all running claude/claude-code CLI processes.
|
// findRuntimeProcesses finds all running runtime CLI processes.
|
||||||
// Excludes Claude.app desktop application and its helpers.
|
// Excludes Claude.app desktop application and its helpers.
|
||||||
func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
func (c *OrphanProcessCheck) findRuntimeProcesses() ([]processInfo, error) {
|
||||||
var procs []processInfo
|
var procs []processInfo
|
||||||
|
|
||||||
// Use ps to find claude processes
|
// Use ps to find runtime processes
|
||||||
// Look for both "claude" and "claude-code" in command
|
|
||||||
out, err := exec.Command("ps", "-eo", "pid,ppid,comm").Output()
|
out, err := exec.Command("ps", "-eo", "pid,ppid,comm").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regex to match claude CLI processes (not Claude.app)
|
// Regex to match runtime CLI processes (not Claude.app)
|
||||||
// Match: "claude" or paths ending in "/claude"
|
// Match: "claude", "claude-code", or "codex" (or paths ending in those)
|
||||||
claudePattern := regexp.MustCompile(`(?i)(^claude$|/claude$)`)
|
runtimePattern := regexp.MustCompile(`(?i)(^claude$|/claude$|^claude-code$|/claude-code$|^codex$|/codex$)`)
|
||||||
|
|
||||||
// Pattern to exclude Claude.app and related desktop processes
|
// Pattern to exclude Claude.app and related desktop processes
|
||||||
excludePattern := regexp.MustCompile(`(?i)(Claude\.app|claude-native|chrome-native)`)
|
excludePattern := regexp.MustCompile(`(?i)(Claude\.app|claude-native|chrome-native)`)
|
||||||
@@ -487,7 +486,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if command matches claude CLI
|
// Check if command matches runtime CLI
|
||||||
cmd := strings.Join(fields[2:], " ")
|
cmd := strings.Join(fields[2:], " ")
|
||||||
|
|
||||||
// Skip desktop app processes
|
// Skip desktop app processes
|
||||||
@@ -495,8 +494,8 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only match CLI claude processes
|
// Only match CLI runtime processes
|
||||||
if !claudePattern.MatchString(cmd) {
|
if !runtimePattern.MatchString(cmd) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +517,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
|||||||
return procs, nil
|
return procs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOrphanProcess checks if a Claude process is orphaned.
|
// isOrphanProcess checks if a runtime process is orphaned.
|
||||||
// A process is orphaned if its parent (or ancestor) is not a tmux session.
|
// A process is orphaned if its parent (or ancestor) is not a tmux session.
|
||||||
func (c *OrphanProcessCheck) isOrphanProcess(proc processInfo, tmuxPIDs map[int]bool) bool {
|
func (c *OrphanProcessCheck) isOrphanProcess(proc processInfo, tmuxPIDs map[int]bool) bool {
|
||||||
// Walk up the process tree looking for a tmux parent
|
// Walk up the process tree looking for a tmux parent
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// timeNow is a function that returns the current time. It can be overridden in tests.
|
// timeNow is a function that returns the current time. It can be overridden in tests.
|
||||||
@@ -334,7 +335,7 @@ func (m *Mailbox) markReadBeads(id string) error {
|
|||||||
func (m *Mailbox) closeInDir(id, beadsDir string) error {
|
func (m *Mailbox) closeInDir(id, beadsDir string) error {
|
||||||
args := []string{"close", id}
|
args := []string{"close", id}
|
||||||
// Pass session ID for work attribution if available
|
// Pass session ID for work attribution if available
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||||
args = append(args, "--session="+sessionID)
|
args = append(args, "--session="+sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
)
|
)
|
||||||
@@ -195,15 +196,17 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Claude is ready (non-blocking poll)
|
// Check if runtime is ready (non-blocking poll)
|
||||||
err = t.WaitForClaudeReady(ps.Session, timeout)
|
rigPath := filepath.Join(townRoot, ps.Rig)
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||||
|
err = t.WaitForRuntimeReady(ps.Session, runtimeConfig, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not ready yet - keep in pending
|
// Not ready yet - keep in pending
|
||||||
remaining = append(remaining, ps)
|
remaining = append(remaining, ps)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude is ready - send trigger
|
// Runtime is ready - send trigger
|
||||||
triggerMsg := "Begin."
|
triggerMsg := "Begin."
|
||||||
if err := t.NudgeSession(ps.Session, triggerMsg); err != nil {
|
if err := t.NudgeSession(ps.Session, triggerMsg); err != nil {
|
||||||
result.Error = fmt.Errorf("nudging session: %w", err)
|
result.Error = fmt.Errorf("nudging session: %w", err)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/claude"
|
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
)
|
)
|
||||||
@@ -59,9 +59,9 @@ type SessionStartOptions struct {
|
|||||||
// Account specifies the account handle to use (overrides default).
|
// Account specifies the account handle to use (overrides default).
|
||||||
Account string
|
Account string
|
||||||
|
|
||||||
// ClaudeConfigDir is resolved CLAUDE_CONFIG_DIR for the account.
|
// RuntimeConfigDir is resolved config directory for the runtime account.
|
||||||
// If set, this is injected as an environment variable.
|
// If set, this is injected as an environment variable.
|
||||||
ClaudeConfigDir string
|
RuntimeConfigDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionInfo contains information about a running polecat session.
|
// SessionInfo contains information about a running polecat session.
|
||||||
@@ -134,11 +134,13 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
workDir = m.polecatDir(polecat)
|
workDir = m.polecatDir(polecat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
|
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||||
// write into the source repo. Claude walks up the tree to find settings.
|
|
||||||
|
// Ensure runtime settings exist in polecats/ (not polecats/<name>/) so we don't
|
||||||
|
// write into the source repo. Runtime walks up the tree to find settings.
|
||||||
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
||||||
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
|
if err := runtime.EnsureSettingsForRole(polecatsDir, "polecat", runtimeConfig); err != nil {
|
||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
return fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
@@ -150,9 +152,9 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name))
|
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name))
|
||||||
debugSession("SetEnvironment GT_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
|
debugSession("SetEnvironment GT_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
|
||||||
|
|
||||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
// Set runtime config dir for account selection (non-fatal)
|
||||||
if opts.ClaudeConfigDir != "" {
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
|
||||||
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
|
debugSession("SetEnvironment "+runtimeConfig.Session.ConfigDirEnv, m.tmux.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, opts.RuntimeConfigDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set beads environment for worktree polecats (non-fatal)
|
// Set beads environment for worktree polecats (non-fatal)
|
||||||
@@ -183,6 +185,10 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
if command == "" {
|
if command == "" {
|
||||||
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
|
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
|
||||||
}
|
}
|
||||||
|
// Prepend runtime config dir env if needed
|
||||||
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
|
||||||
|
command = config.PrependEnv(command, map[string]string{runtimeConfig.Session.ConfigDirEnv: opts.RuntimeConfigDir})
|
||||||
|
}
|
||||||
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
||||||
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
||||||
_ = m.tmux.KillSession(sessionID)
|
_ = m.tmux.KillSession(sessionID)
|
||||||
@@ -198,8 +204,9 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
// Accept bypass permissions warning dialog if it appears
|
// Accept bypass permissions warning dialog if it appears
|
||||||
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
|
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
|
||||||
|
|
||||||
// Wait for Claude to be fully ready
|
// Wait for runtime to be fully ready at the prompt (not just started)
|
||||||
time.Sleep(8 * time.Second)
|
runtime.SleepForReadyDelay(runtimeConfig)
|
||||||
|
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/claude"
|
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
"github.com/steveyegge/gastown/internal/mrqueue"
|
"github.com/steveyegge/gastown/internal/mrqueue"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/util"
|
"github.com/steveyegge/gastown/internal/util"
|
||||||
@@ -166,11 +166,12 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
refineryRigDir = m.workDir
|
refineryRigDir = m.workDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
|
// Ensure runtime settings exist in refinery/ (not refinery/rig/) so we don't
|
||||||
// write into the source repo. Claude walks up the tree to find settings.
|
// write into the source repo. Runtime walks up the tree to find settings.
|
||||||
refineryParentDir := filepath.Join(m.rig.Path, "refinery")
|
refineryParentDir := filepath.Join(m.rig.Path, "refinery")
|
||||||
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
|
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
if err := runtime.EnsureSettingsForRole(refineryParentDir, "refinery", runtimeConfig); err != nil {
|
||||||
|
return fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.NewSession(sessionID, refineryRigDir); err != nil {
|
if err := t.NewSession(sessionID, refineryRigDir); err != nil {
|
||||||
@@ -222,15 +223,17 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Claude to start and show its prompt (non-fatal)
|
// Wait for Claude to start and show its prompt (non-fatal)
|
||||||
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
|
// WaitForRuntimeReady waits for the runtime to be ready
|
||||||
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
|
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
|
||||||
// Non-fatal - try to continue anyway
|
// Non-fatal - try to continue anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept bypass permissions warning dialog if it appears.
|
// Accept bypass permissions warning dialog if it appears.
|
||||||
_ = t.AcceptBypassPermissionsWarning(sessionID)
|
_ = t.AcceptBypassPermissionsWarning(sessionID)
|
||||||
|
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
// Wait for runtime to be fully ready
|
||||||
|
runtime.SleepForReadyDelay(runtimeConfig)
|
||||||
|
_ = runtime.RunStartupFallback(t, sessionID, "refinery", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
address := fmt.Sprintf("%s/refinery", m.rig.Name)
|
address := fmt.Sprintf("%s/refinery", m.rig.Name)
|
||||||
|
|||||||
@@ -405,6 +405,12 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
|||||||
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
|
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
|
||||||
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
|
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
|
||||||
}
|
}
|
||||||
|
// Create refinery hooks for patrol triggering (at refinery/ level, not rig/)
|
||||||
|
refineryPath := filepath.Dir(refineryRigPath)
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||||
|
if err := m.createPatrolHooks(refineryPath, runtimeConfig); err != nil {
|
||||||
|
fmt.Printf(" Warning: Could not create refinery hooks: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create empty crew directory with README (crew members added via gt crew add)
|
// Create empty crew directory with README (crew members added via gt crew add)
|
||||||
crewPath := filepath.Join(rigPath, "crew")
|
crewPath := filepath.Join(rigPath, "crew")
|
||||||
@@ -439,6 +445,10 @@ Use crew for your own workspace. Polecats are for batch work dispatch.
|
|||||||
if err := os.MkdirAll(witnessPath, 0755); err != nil {
|
if err := os.MkdirAll(witnessPath, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("creating witness dir: %w", err)
|
return nil, fmt.Errorf("creating witness dir: %w", err)
|
||||||
}
|
}
|
||||||
|
// Create witness hooks for patrol triggering
|
||||||
|
if err := m.createPatrolHooks(witnessPath, runtimeConfig); err != nil {
|
||||||
|
fmt.Printf(" Warning: Could not create witness hooks: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create polecats directory (empty)
|
// Create polecats directory (empty)
|
||||||
polecatsPath := filepath.Join(rigPath, "polecats")
|
polecatsPath := filepath.Join(rigPath, "polecats")
|
||||||
@@ -917,6 +927,65 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName
|
|||||||
return os.WriteFile(claudePath, []byte(content), 0644)
|
return os.WriteFile(claudePath, []byte(content), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createPatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
||||||
|
// These hooks trigger gt prime on session start and inject mail, enabling
|
||||||
|
// autonomous patrol execution for Witness and Refinery roles.
|
||||||
|
func (m *Manager) createPatrolHooks(workspacePath string, runtimeConfig *config.RuntimeConfig) error {
|
||||||
|
if runtimeConfig == nil || runtimeConfig.Hooks == nil || runtimeConfig.Hooks.Provider != "claude" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if runtimeConfig.Hooks.Dir == "" || runtimeConfig.Hooks.SettingsFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsDir := filepath.Join(workspacePath, runtimeConfig.Hooks.Dir)
|
||||||
|
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating settings dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard patrol hooks - same as deacon
|
||||||
|
hooksJSON := `{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "gt prime && gt mail check --inject"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "gt prime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "gt mail check --inject"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
settingsPath := filepath.Join(settingsDir, runtimeConfig.Hooks.SettingsFile)
|
||||||
|
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database.
|
// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database.
|
||||||
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
|
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
|
||||||
func (m *Manager) seedPatrolMolecules(rigPath string) error {
|
func (m *Manager) seedPatrolMolecules(rigPath string) error {
|
||||||
|
|||||||
91
internal/runtime/runtime.go
Normal file
91
internal/runtime/runtime.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Package runtime provides helpers for runtime-specific integration.
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/claude"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureSettingsForRole installs runtime hook settings when supported.
|
||||||
|
func EnsureSettingsForRole(workDir, role string, rc *config.RuntimeConfig) error {
|
||||||
|
if rc == nil {
|
||||||
|
rc = config.DefaultRuntimeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Hooks == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rc.Hooks.Provider {
|
||||||
|
case "claude":
|
||||||
|
return claude.EnsureSettingsForRoleAt(workDir, role, rc.Hooks.Dir, rc.Hooks.SettingsFile)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionIDFromEnv returns the runtime session ID, if present.
|
||||||
|
// It checks GT_SESSION_ID_ENV first, then falls back to CLAUDE_SESSION_ID.
|
||||||
|
func SessionIDFromEnv() string {
|
||||||
|
if envName := os.Getenv("GT_SESSION_ID_ENV"); envName != "" {
|
||||||
|
if sessionID := os.Getenv(envName); sessionID != "" {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Getenv("CLAUDE_SESSION_ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SleepForReadyDelay sleeps for the runtime's configured readiness delay.
|
||||||
|
func SleepForReadyDelay(rc *config.RuntimeConfig) {
|
||||||
|
if rc == nil || rc.Tmux == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rc.Tmux.ReadyDelayMs <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartupFallbackCommands returns commands that approximate Claude hooks when hooks are unavailable.
|
||||||
|
func StartupFallbackCommands(role string, rc *config.RuntimeConfig) []string {
|
||||||
|
if rc == nil {
|
||||||
|
rc = config.DefaultRuntimeConfig()
|
||||||
|
}
|
||||||
|
if rc.Hooks != nil && rc.Hooks.Provider == "claude" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
role = strings.ToLower(role)
|
||||||
|
command := "gt prime"
|
||||||
|
if isAutonomousRole(role) {
|
||||||
|
command += " && gt mail check --inject"
|
||||||
|
}
|
||||||
|
command += " && gt nudge deacon session-started"
|
||||||
|
|
||||||
|
return []string{command}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStartupFallback sends the startup fallback commands via tmux.
|
||||||
|
func RunStartupFallback(t *tmux.Tmux, sessionID, role string, rc *config.RuntimeConfig) error {
|
||||||
|
commands := StartupFallbackCommands(role, rc)
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if err := t.NudgeSession(sessionID, cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAutonomousRole(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case "polecat", "witness", "refinery", "deacon":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,8 +391,9 @@ func (t *Tmux) GetPaneWorkDir(session string) (string, error) {
|
|||||||
|
|
||||||
// FindSessionByWorkDir finds tmux sessions where the pane's current working directory
|
// FindSessionByWorkDir finds tmux sessions where the pane's current working directory
|
||||||
// matches or is under the target directory. Returns session names that match.
|
// matches or is under the target directory. Returns session names that match.
|
||||||
// If requireAgentRunning is true, only returns sessions that have some non-shell command running.
|
// If processNames is provided, only returns sessions that match those processes.
|
||||||
func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool) ([]string, error) {
|
// If processNames is nil or empty, returns all sessions matching the directory.
|
||||||
|
func (t *Tmux) FindSessionByWorkDir(targetDir string, processNames []string) ([]string, error) {
|
||||||
sessions, err := t.ListSessions()
|
sessions, err := t.ListSessions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -410,14 +412,13 @@ func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool)
|
|||||||
|
|
||||||
// Check if workdir matches target (exact match or subdir)
|
// Check if workdir matches target (exact match or subdir)
|
||||||
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
|
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
|
||||||
if requireAgentRunning {
|
if len(processNames) > 0 {
|
||||||
// Only include if an agent appears to be running
|
if t.IsRuntimeRunning(session, processNames) {
|
||||||
if t.IsAgentRunning(session) {
|
|
||||||
matches = append(matches, session)
|
matches = append(matches, session)
|
||||||
}
|
}
|
||||||
} else {
|
continue
|
||||||
matches = append(matches, session)
|
|
||||||
}
|
}
|
||||||
|
matches = append(matches, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +562,25 @@ func (t *Tmux) IsClaudeRunning(session string) bool {
|
|||||||
return t.IsAgentRunning(session, "node")
|
return t.IsAgentRunning(session, "node")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRuntimeRunning checks if a runtime appears to be running in the session.
|
||||||
|
// Only trusts the pane command - UI markers in scrollback cause false positives.
|
||||||
|
// This is the runtime-config-aware version of IsAgentRunning.
|
||||||
|
func (t *Tmux) IsRuntimeRunning(session string, processNames []string) bool {
|
||||||
|
if len(processNames) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd, err := t.GetPaneCommand(session)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, name := range processNames {
|
||||||
|
if cmd == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
|
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
|
||||||
// Useful for waiting until a shell has started a new process (e.g., claude).
|
// Useful for waiting until a shell has started a new process (e.g., claude).
|
||||||
// Returns nil when a non-excluded command is detected, or error on timeout.
|
// Returns nil when a non-excluded command is detected, or error on timeout.
|
||||||
@@ -609,13 +629,12 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
|
|||||||
return fmt.Errorf("timeout waiting for shell")
|
return fmt.Errorf("timeout waiting for shell")
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
|
// WaitForRuntimeReady polls until the runtime's prompt indicator appears in the pane.
|
||||||
// Claude is ready when we see "> " at the start of a line (the input prompt).
|
// Runtime is ready when we see the configured prompt prefix at the start of a line.
|
||||||
// This is more reliable than just checking if node is running.
|
|
||||||
//
|
//
|
||||||
// IMPORTANT: Bootstrap vs Steady-State Observation
|
// IMPORTANT: Bootstrap vs Steady-State Observation
|
||||||
//
|
//
|
||||||
// This function uses regex to detect Claude's prompt - a ZFC violation.
|
// This function uses regex to detect runtime prompts - a ZFC violation.
|
||||||
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
|
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
|
||||||
//
|
//
|
||||||
// Bootstrap (acceptable):
|
// Bootstrap (acceptable):
|
||||||
@@ -632,7 +651,24 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
|
|||||||
//
|
//
|
||||||
// See: gt deacon pending (ZFC-compliant AI observation)
|
// See: gt deacon pending (ZFC-compliant AI observation)
|
||||||
// See: gt deacon trigger-pending (bootstrap mode, regex-based)
|
// See: gt deacon trigger-pending (bootstrap mode, regex-based)
|
||||||
func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
|
func (t *Tmux) WaitForRuntimeReady(session string, rc *config.RuntimeConfig, timeout time.Duration) error {
|
||||||
|
if rc == nil || rc.Tmux == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.Tmux.ReadyPromptPrefix == "" {
|
||||||
|
if rc.Tmux.ReadyDelayMs <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Fallback to fixed delay when prompt detection is unavailable.
|
||||||
|
delay := time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond
|
||||||
|
if delay > timeout {
|
||||||
|
delay = timeout
|
||||||
|
}
|
||||||
|
time.Sleep(delay)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
// Capture last few lines of the pane
|
// Capture last few lines of the pane
|
||||||
@@ -641,16 +677,17 @@ func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
|
|||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Look for Claude's prompt indicator "> " at start of line
|
// Look for runtime prompt indicator at start of line
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trimmed, "> ") || trimmed == ">" {
|
prefix := strings.TrimSpace(rc.Tmux.ReadyPromptPrefix)
|
||||||
|
if strings.HasPrefix(trimmed, rc.Tmux.ReadyPromptPrefix) || (prefix != "" && trimmed == prefix) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("timeout waiting for Claude prompt")
|
return fmt.Errorf("timeout waiting for runtime prompt")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSessionInfo returns detailed information about a session.
|
// GetSessionInfo returns detailed information about a session.
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
// Export GT_ROLE and BD_ACTOR 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
|
||||||
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
|
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
|
||||||
command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "")
|
command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "")
|
||||||
|
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||||
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
||||||
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
||||||
_ = t.KillSession(sessionID)
|
_ = t.KillSession(sessionID)
|
||||||
@@ -193,9 +194,8 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
return fmt.Errorf("starting Claude agent: %w", err)
|
return fmt.Errorf("starting Claude agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Claude to start and show its prompt (non-fatal)
|
// Wait for runtime to start and show its prompt (non-fatal)
|
||||||
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
|
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
|
||||||
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
|
|
||||||
// Non-fatal - try to continue anyway
|
// Non-fatal - try to continue anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user