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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -180,6 +181,18 @@ gt convoy create "Auth System" issue-101 issue-102 --notify
|
||||
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
|
||||
|
||||
**Best for:** Predefined, repeatable processes
|
||||
@@ -258,6 +271,30 @@ gt sling bug-101 myproject/my-agent
|
||||
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
|
||||
|
||||
### Workspace Management
|
||||
@@ -314,6 +351,10 @@ bd mol pour <formula> # Create trackable instance
|
||||
bd mol list # List active instances
|
||||
```
|
||||
|
||||
## Cooking Formulas
|
||||
|
||||
Gas Town includes built-in formulas for common workflows. See `.beads/formulas/` for available recipes.
|
||||
|
||||
## Dashboard
|
||||
|
||||
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 |
|
||||
|------|---------|-------|---------|
|
||||
| **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
|
||||
|
||||
@@ -159,16 +160,17 @@ Gas Town supports two operational modes:
|
||||
|
||||
### 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
|
||||
# Create and assign work
|
||||
gt convoy create "Fix bugs" issue-123
|
||||
gt sling issue-123 myproject
|
||||
|
||||
# Run Claude manually
|
||||
# Run runtime manually
|
||||
cd ~/gt/myproject/polecats/<worker>
|
||||
claude --resume
|
||||
claude --resume # Claude Code
|
||||
# or: codex # Codex CLI
|
||||
|
||||
# Check progress
|
||||
gt convoy list
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -766,7 +768,7 @@ func (b *Beads) Update(id string, opts UpdateOptions) error {
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (b *Beads) Close(ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
@@ -776,7 +778,7 @@ func (b *Beads) Close(ids ...string) error {
|
||||
args := append([]string{"close"}, ids...)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -785,7 +787,7 @@ func (b *Beads) Close(ids ...string) error {
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
@@ -796,7 +798,7 @@ func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
||||
args = append(args, "--reason="+reason)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
)
|
||||
|
||||
// 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
|
||||
if cp.SessionID == "" {
|
||||
cp.SessionID = os.Getenv("CLAUDE_SESSION_ID")
|
||||
cp.SessionID = runtime.SessionIDFromEnv()
|
||||
if cp.SessionID == "" {
|
||||
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,
|
||||
// so our settings.json is the only one Claude Code sees.
|
||||
func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
claudeDir := filepath.Join(workDir, ".claude")
|
||||
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||
return EnsureSettingsAt(workDir, roleType, ".claude", "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 _, err := os.Stat(settingsPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create .claude directory if needed
|
||||
// Create settings directory if needed
|
||||
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
|
||||
@@ -78,3 +85,8 @@ func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
func EnsureSettingsForRole(workDir, role string) error {
|
||||
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
|
||||
}
|
||||
|
||||
// Resolve account for Claude config
|
||||
// Resolve account for runtime config
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||
|
||||
// Check if session exists
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
@@ -95,15 +97,15 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
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
|
||||
// a different mechanism)
|
||||
if !hasSession {
|
||||
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, true)
|
||||
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, runtimeConfig.Tmux.ProcessNames)
|
||||
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]
|
||||
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("⚠"),
|
||||
existingSession)
|
||||
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_CREW", name)
|
||||
|
||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
||||
if claudeConfigDir != "" {
|
||||
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
|
||||
// Set runtime config dir for account selection (non-fatal)
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Use respawn-pane to replace shell with Claude directly
|
||||
// This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell)
|
||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
||||
// Use respawn-pane to replace shell with runtime directly
|
||||
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
|
||||
// Pass "gt prime" as initial prompt if supported
|
||||
// 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("starting claude: %w", err)
|
||||
return fmt.Errorf("starting runtime: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created session for %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
} 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
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving agent: %w", err)
|
||||
}
|
||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||
// Claude has exited, restart it using respawn-pane
|
||||
fmt.Printf("Claude exited, restarting...\n")
|
||||
// Runtime has exited, restart it using respawn-pane
|
||||
fmt.Printf("Runtime exited, restarting...\n")
|
||||
|
||||
// Get pane ID for respawn
|
||||
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)
|
||||
}
|
||||
|
||||
// Use respawn-pane to replace shell with Claude directly
|
||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
||||
// Use respawn-pane to replace shell with runtime directly
|
||||
// Pass "gt prime" as initial prompt if supported
|
||||
// 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("restarting claude: %w", err)
|
||||
return fmt.Errorf("restarting runtime: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
|
||||
}, 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 {
|
||||
shells := constants.SupportedShells
|
||||
for _, shell := range shells {
|
||||
@@ -170,6 +170,29 @@ func execAgent(cfg *config.RuntimeConfig, prompt string) error {
|
||||
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.
|
||||
func isInTmuxSession(targetSession string) bool {
|
||||
// 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/crew"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/townlog"
|
||||
@@ -163,7 +164,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
// Default: CLOSE the agent bead (preserves CV history)
|
||||
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)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
@@ -236,9 +237,9 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Use manager's Start() with refresh options
|
||||
err = crewMgr.Start(name, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "refresh", // Startup nudge topic
|
||||
Interactive: true, // No --dangerously-skip-permissions
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "refresh", // Startup nudge topic
|
||||
Interactive: true, // No --dangerously-skip-permissions
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -347,8 +348,8 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
|
||||
// Use manager's Start() with restart options
|
||||
// Start() will create workspace if needed (idempotent)
|
||||
err = crewMgr.Start(name, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -427,8 +428,8 @@ func runCrewRestartAll() error {
|
||||
|
||||
// Use manager's Start() with restart options
|
||||
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
@@ -112,7 +113,7 @@ var deaconTriggerPendingCmd = &cobra.Command{
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
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)
|
||||
|
||||
runtimeConfig := config.LoadRuntimeConfig("")
|
||||
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
Recipient: "deacon",
|
||||
|
||||
@@ -357,9 +357,13 @@ func buildRestartCommand(sessionName string) (string, error) {
|
||||
// Build environment exports - role vars first, then Claude vars
|
||||
var exports []string
|
||||
if gtRole != "" {
|
||||
exports = append(exports, fmt.Sprintf("GT_ROLE=%s", gtRole))
|
||||
exports = append(exports, fmt.Sprintf("BD_ACTOR=%s", gtRole))
|
||||
exports = append(exports, fmt.Sprintf("GIT_AUTHOR_NAME=%s", gtRole))
|
||||
runtimeConfig := config.LoadRuntimeConfig("")
|
||||
exports = append(exports, "GT_ROLE="+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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"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)
|
||||
closeArgs := []string{"close", existing.ID, "--force",
|
||||
"--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)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
@@ -89,7 +90,6 @@ Examples:
|
||||
RunE: runPolecatRemove,
|
||||
}
|
||||
|
||||
|
||||
var polecatSyncCmd = &cobra.Command{
|
||||
Use: "sync <rig>/<polecat>",
|
||||
Short: "Sync beads for a polecat",
|
||||
@@ -129,15 +129,15 @@ Examples:
|
||||
}
|
||||
|
||||
var (
|
||||
polecatSyncAll bool
|
||||
polecatSyncFromMain bool
|
||||
polecatStatusJSON bool
|
||||
polecatGitStateJSON bool
|
||||
polecatGCDryRun bool
|
||||
polecatNukeAll bool
|
||||
polecatNukeDryRun bool
|
||||
polecatNukeForce bool
|
||||
polecatCheckRecoveryJSON bool
|
||||
polecatSyncAll bool
|
||||
polecatSyncFromMain bool
|
||||
polecatStatusJSON bool
|
||||
polecatGitStateJSON bool
|
||||
polecatGCDryRun bool
|
||||
polecatNukeAll bool
|
||||
polecatNukeDryRun bool
|
||||
polecatNukeForce bool
|
||||
polecatCheckRecoveryJSON bool
|
||||
)
|
||||
|
||||
var polecatGCCmd = &cobra.Command{
|
||||
@@ -975,7 +975,7 @@ type RecoveryStatus struct {
|
||||
NeedsRecovery bool `json:"needs_recovery"`
|
||||
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
}
|
||||
|
||||
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)
|
||||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||||
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
||||
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
|
||||
@@ -139,7 +139,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resolve account for Claude config
|
||||
// Resolve account for runtime config
|
||||
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
|
||||
if err != nil {
|
||||
@@ -158,7 +158,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
||||
if !running {
|
||||
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
||||
startOpts := polecat.SessionStartOptions{
|
||||
ClaudeConfigDir: claudeConfigDir,
|
||||
RuntimeConfigDir: claudeConfigDir,
|
||||
}
|
||||
if 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/lock"
|
||||
"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/templates"
|
||||
@@ -1499,22 +1500,17 @@ func outputSessionMetadata(ctx RoleContext) {
|
||||
// resolveSessionIDForPrime finds the session ID from available sources.
|
||||
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
|
||||
func resolveSessionIDForPrime(actor string) string {
|
||||
// 1. GT_SESSION_ID (new canonical)
|
||||
if id := os.Getenv("GT_SESSION_ID"); id != "" {
|
||||
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
|
||||
if id := runtime.SessionIDFromEnv(); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
// 2. CLAUDE_SESSION_ID (legacy/Claude Code)
|
||||
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
// 3. Persisted session file (from gt prime --hook)
|
||||
// 2. Persisted session file (from gt prime --hook)
|
||||
if id := ReadPersistedSessionID(); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
// 4. Fallback to generated identifier
|
||||
// 3. Fallback to generated identifier
|
||||
return fmt.Sprintf("%s-%d", actor, os.Getpid())
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/refinery"
|
||||
"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/tmux"
|
||||
"github.com/steveyegge/gastown/internal/witness"
|
||||
@@ -317,6 +319,86 @@ func discoverAllRigs(townRoot string) ([]*rig.Rig, error) {
|
||||
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 {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/swarm"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
@@ -808,7 +809,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Close the swarm epic in beads
|
||||
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)
|
||||
}
|
||||
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
|
||||
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)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/formula"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
@@ -322,7 +323,7 @@ func runSynthesisClose(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Close the convoy
|
||||
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)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/crew"
|
||||
"github.com/steveyegge/gastown/internal/daemon"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/mayor"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"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/tmux"
|
||||
"github.com/steveyegge/gastown/internal/witness"
|
||||
@@ -249,6 +252,127 @@ func ensureDaemon(townRoot string) error {
|
||||
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.
|
||||
func discoverRigs(townRoot string) []string {
|
||||
var rigs []string
|
||||
|
||||
@@ -5,8 +5,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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/tmux"
|
||||
"github.com/steveyegge/gastown/internal/witness"
|
||||
@@ -262,6 +269,87 @@ func witnessSessionName(rigName string) string {
|
||||
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 {
|
||||
rigName := ""
|
||||
if len(args) > 0 {
|
||||
|
||||
@@ -726,15 +726,7 @@ func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
|
||||
if settings.Runtime == nil {
|
||||
return DefaultRuntimeConfig()
|
||||
}
|
||||
// Fill in defaults for empty fields
|
||||
rc := settings.Runtime
|
||||
if rc.Command == "" {
|
||||
rc.Command = "claude"
|
||||
}
|
||||
if rc.Args == nil {
|
||||
rc.Args = []string{"--dangerously-skip-permissions"}
|
||||
}
|
||||
return rc
|
||||
return normalizeRuntimeConfig(settings.Runtime)
|
||||
}
|
||||
|
||||
// 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
|
||||
var exports []string
|
||||
for k, v := range envVars {
|
||||
for k, v := range resolvedEnv {
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
// but uses agentOverride if non-empty.
|
||||
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) {
|
||||
rc := DefaultRuntimeConfig()
|
||||
if rc.Provider != "claude" {
|
||||
t.Errorf("Provider = %q, want %q", rc.Provider, "claude")
|
||||
}
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||
}
|
||||
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -220,6 +221,10 @@ type CrewConfig struct {
|
||||
// This allows switching between different LLM backends (claude, aider, etc.)
|
||||
// without modifying startup code.
|
||||
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").
|
||||
// Default: "claude"
|
||||
Command string `json:"command,omitempty"`
|
||||
@@ -232,33 +237,78 @@ type RuntimeConfig struct {
|
||||
// For claude, this is passed as the prompt argument.
|
||||
// Empty by default (hooks handle context).
|
||||
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.
|
||||
func DefaultRuntimeConfig() *RuntimeConfig {
|
||||
return &RuntimeConfig{
|
||||
Command: "claude",
|
||||
Args: []string{"--dangerously-skip-permissions"},
|
||||
}
|
||||
return normalizeRuntimeConfig(&RuntimeConfig{Provider: "claude"})
|
||||
}
|
||||
|
||||
// BuildCommand returns the full command line string.
|
||||
// For use with tmux SendKeys.
|
||||
func (rc *RuntimeConfig) BuildCommand() string {
|
||||
if rc == nil {
|
||||
return DefaultRuntimeConfig().BuildCommand()
|
||||
}
|
||||
resolved := normalizeRuntimeConfig(rc)
|
||||
|
||||
cmd := rc.Command
|
||||
if cmd == "" {
|
||||
cmd = "claude"
|
||||
}
|
||||
|
||||
// Build args
|
||||
args := rc.Args
|
||||
if args == nil {
|
||||
args = []string{"--dangerously-skip-permissions"}
|
||||
}
|
||||
cmd := resolved.Command
|
||||
args := resolved.Args
|
||||
|
||||
// Combine command and args
|
||||
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 prompt is provided, it overrides the config's InitialPrompt.
|
||||
func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
||||
base := rc.BuildCommand()
|
||||
resolved := normalizeRuntimeConfig(rc)
|
||||
base := resolved.BuildCommand()
|
||||
|
||||
// Use provided prompt or fall back to config
|
||||
p := prompt
|
||||
if p == "" && rc != nil {
|
||||
p = rc.InitialPrompt
|
||||
if p == "" {
|
||||
p = resolved.InitialPrompt
|
||||
}
|
||||
|
||||
if p == "" {
|
||||
if p == "" || resolved.PromptMode == "none" {
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -287,6 +338,197 @@ func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
||||
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.
|
||||
func quoteForShell(s string) string {
|
||||
// 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)
|
||||
// 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()
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// The timeout is short (2s) to avoid blocking the heartbeat.
|
||||
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))
|
||||
|
||||
// Trigger pending spawns (uses WaitForClaudeReady with short timeout)
|
||||
// Trigger pending spawns (uses WaitForRuntimeReady with short timeout)
|
||||
results, err := polecat.TriggerPendingSpawns(d.config.TownRoot, triggerTimeout)
|
||||
if err != nil {
|
||||
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
|
||||
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
|
||||
if parsed.RoleType == "polecat" {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (c *OrphanSessionCheck) isValidSession(sess string, validRigs []string, may
|
||||
return true
|
||||
}
|
||||
|
||||
// OrphanProcessCheck detects orphaned Claude/claude-code processes
|
||||
// OrphanProcessCheck detects orphaned runtime processes
|
||||
// that are not associated with a Gas Town tmux session.
|
||||
type OrphanProcessCheck struct {
|
||||
FixableCheck
|
||||
@@ -238,13 +238,13 @@ func NewOrphanProcessCheck() *OrphanProcessCheck {
|
||||
FixableCheck: FixableCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
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 {
|
||||
// Get list of tmux session PIDs
|
||||
tmuxPIDs, err := c.getTmuxSessionPIDs()
|
||||
@@ -257,30 +257,30 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Find Claude processes
|
||||
claudeProcs, err := c.findClaudeProcesses()
|
||||
// Find runtime processes
|
||||
runtimeProcs, err := c.findRuntimeProcesses()
|
||||
if err != nil {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusWarning,
|
||||
Message: "Could not list Claude processes",
|
||||
Message: "Could not list runtime processes",
|
||||
Details: []string{err.Error()},
|
||||
}
|
||||
}
|
||||
|
||||
if len(claudeProcs) == 0 {
|
||||
if len(runtimeProcs) == 0 {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
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 validCount int
|
||||
|
||||
for _, proc := range claudeProcs {
|
||||
for _, proc := range runtimeProcs {
|
||||
if c.isOrphanProcess(proc, tmuxPIDs) {
|
||||
orphans = append(orphans, proc)
|
||||
} else {
|
||||
@@ -298,7 +298,7 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
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{
|
||||
Name: c.Name(),
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// findClaudeProcesses finds all running claude/claude-code CLI processes.
|
||||
// findRuntimeProcesses finds all running runtime CLI processes.
|
||||
// Excludes Claude.app desktop application and its helpers.
|
||||
func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
||||
func (c *OrphanProcessCheck) findRuntimeProcesses() ([]processInfo, error) {
|
||||
var procs []processInfo
|
||||
|
||||
// Use ps to find claude processes
|
||||
// Look for both "claude" and "claude-code" in command
|
||||
// Use ps to find runtime processes
|
||||
out, err := exec.Command("ps", "-eo", "pid,ppid,comm").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Regex to match claude CLI processes (not Claude.app)
|
||||
// Match: "claude" or paths ending in "/claude"
|
||||
claudePattern := regexp.MustCompile(`(?i)(^claude$|/claude$)`)
|
||||
// Regex to match runtime CLI processes (not Claude.app)
|
||||
// Match: "claude", "claude-code", or "codex" (or paths ending in those)
|
||||
runtimePattern := regexp.MustCompile(`(?i)(^claude$|/claude$|^claude-code$|/claude-code$|^codex$|/codex$)`)
|
||||
|
||||
// Pattern to exclude Claude.app and related desktop processes
|
||||
excludePattern := regexp.MustCompile(`(?i)(Claude\.app|claude-native|chrome-native)`)
|
||||
@@ -487,7 +486,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if command matches claude CLI
|
||||
// Check if command matches runtime CLI
|
||||
cmd := strings.Join(fields[2:], " ")
|
||||
|
||||
// Skip desktop app processes
|
||||
@@ -495,8 +494,8 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only match CLI claude processes
|
||||
if !claudePattern.MatchString(cmd) {
|
||||
// Only match CLI runtime processes
|
||||
if !runtimePattern.MatchString(cmd) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -518,7 +517,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
|
||||
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.
|
||||
func (c *OrphanProcessCheck) isOrphanProcess(proc processInfo, tmuxPIDs map[int]bool) bool {
|
||||
// Walk up the process tree looking for a tmux parent
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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.
|
||||
@@ -334,7 +335,7 @@ func (m *Mailbox) markReadBeads(id string) error {
|
||||
func (m *Mailbox) closeInDir(id, beadsDir string) error {
|
||||
args := []string{"close", id}
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
@@ -195,15 +196,17 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if Claude is ready (non-blocking poll)
|
||||
err = t.WaitForClaudeReady(ps.Session, timeout)
|
||||
// Check if runtime is ready (non-blocking poll)
|
||||
rigPath := filepath.Join(townRoot, ps.Rig)
|
||||
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||
err = t.WaitForRuntimeReady(ps.Session, runtimeConfig, timeout)
|
||||
if err != nil {
|
||||
// Not ready yet - keep in pending
|
||||
remaining = append(remaining, ps)
|
||||
continue
|
||||
}
|
||||
|
||||
// Claude is ready - send trigger
|
||||
// Runtime is ready - send trigger
|
||||
triggerMsg := "Begin."
|
||||
if err := t.NudgeSession(ps.Session, triggerMsg); err != nil {
|
||||
result.Error = fmt.Errorf("nudging session: %w", err)
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"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/tmux"
|
||||
)
|
||||
@@ -59,9 +59,9 @@ type SessionStartOptions struct {
|
||||
// Account specifies the account handle to use (overrides default).
|
||||
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.
|
||||
ClaudeConfigDir string
|
||||
RuntimeConfigDir string
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
|
||||
// write into the source repo. Claude walks up the tree to find settings.
|
||||
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||
|
||||
// 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")
|
||||
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
|
||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||
if err := runtime.EnsureSettingsForRole(polecatsDir, "polecat", runtimeConfig); err != nil {
|
||||
return fmt.Errorf("ensuring runtime settings: %w", err)
|
||||
}
|
||||
|
||||
// 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_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
|
||||
|
||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
||||
if opts.ClaudeConfigDir != "" {
|
||||
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
|
||||
// Set runtime config dir for account selection (non-fatal)
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
|
||||
debugSession("SetEnvironment "+runtimeConfig.Session.ConfigDirEnv, m.tmux.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, opts.RuntimeConfigDir))
|
||||
}
|
||||
|
||||
// Set beads environment for worktree polecats (non-fatal)
|
||||
@@ -183,6 +185,10 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
||||
if command == "" {
|
||||
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)
|
||||
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
||||
_ = 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
|
||||
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
|
||||
|
||||
// Wait for Claude to be fully ready
|
||||
time.Sleep(8 * time.Second)
|
||||
// Wait for runtime to be fully ready at the prompt (not just started)
|
||||
runtime.SleepForReadyDelay(runtimeConfig)
|
||||
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
||||
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/mrqueue"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/util"
|
||||
@@ -166,11 +166,12 @@ func (m *Manager) Start(foreground bool) error {
|
||||
refineryRigDir = m.workDir
|
||||
}
|
||||
|
||||
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
|
||||
// write into the source repo. Claude walks up the tree to find settings.
|
||||
// Ensure runtime settings exist in refinery/ (not refinery/rig/) so we don't
|
||||
// write into the source repo. Runtime walks up the tree to find settings.
|
||||
refineryParentDir := filepath.Join(m.rig.Path, "refinery")
|
||||
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
|
||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
|
||||
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 {
|
||||
@@ -222,15 +223,17 @@ func (m *Manager) Start(foreground bool) error {
|
||||
}
|
||||
|
||||
// Wait for Claude to start and show its prompt (non-fatal)
|
||||
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
|
||||
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
|
||||
// WaitForRuntimeReady waits for the runtime to be ready
|
||||
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal - try to continue anyway
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
_ = 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
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"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
|
||||
// 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.
|
||||
func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool) ([]string, error) {
|
||||
// If processNames is provided, only returns sessions that match those processes.
|
||||
// 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()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -410,14 +412,13 @@ func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool)
|
||||
|
||||
// Check if workdir matches target (exact match or subdir)
|
||||
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
|
||||
if requireAgentRunning {
|
||||
// Only include if an agent appears to be running
|
||||
if t.IsAgentRunning(session) {
|
||||
if len(processNames) > 0 {
|
||||
if t.IsRuntimeRunning(session, processNames) {
|
||||
matches = append(matches, session)
|
||||
}
|
||||
} else {
|
||||
matches = append(matches, session)
|
||||
continue
|
||||
}
|
||||
matches = append(matches, session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +562,25 @@ func (t *Tmux) IsClaudeRunning(session string) bool {
|
||||
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.
|
||||
// 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.
|
||||
@@ -609,13 +629,12 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
|
||||
return fmt.Errorf("timeout waiting for shell")
|
||||
}
|
||||
|
||||
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
|
||||
// Claude is ready when we see "> " at the start of a line (the input prompt).
|
||||
// This is more reliable than just checking if node is running.
|
||||
// WaitForRuntimeReady polls until the runtime's prompt indicator appears in the pane.
|
||||
// Runtime is ready when we see the configured prompt prefix at the start of a line.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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 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)
|
||||
for time.Now().Before(deadline) {
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
// Look for Claude's prompt indicator "> " at start of line
|
||||
// Look for runtime prompt indicator at start of line
|
||||
for _, line := range lines {
|
||||
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
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
|
||||
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)
|
||||
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
||||
_ = t.KillSession(sessionID)
|
||||
@@ -193,9 +194,8 @@ func (m *Manager) Start(foreground bool) error {
|
||||
return fmt.Errorf("starting Claude agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start and show its prompt (non-fatal)
|
||||
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
|
||||
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
|
||||
// Wait for runtime to start and show its prompt (non-fatal)
|
||||
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal - try to continue anyway
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user