diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d1c8b02..b25befd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4866,11 +4866,25 @@ func (c *CLI) repair(args []string) error { fmt.Println("āœ“ State repaired successfully") if data, ok := resp.Data.(map[string]interface{}); ok { - if removed, ok := data["agents_removed"].(float64); ok && removed > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", int(removed)) + removed := int(data["agents_removed"].(float64)) + fixed := int(data["issues_fixed"].(float64)) + created, _ := data["agents_created"].(float64) + wsCreated, _ := data["workspaces_created"].(float64) + + if removed > 0 { + fmt.Printf(" Removed: %d dead agent(s)\n", removed) + } + if fixed > 0 { + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", fixed) + } + if created > 0 { + fmt.Printf(" Created: %d core agent(s)\n", int(created)) } - if fixed, ok := data["issues_fixed"].(float64); ok && fixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", int(fixed)) + if wsCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", int(wsCreated)) + } + if removed == 0 && fixed == 0 && created == 0 && wsCreated == 0 { + fmt.Println(" No issues found, no changes needed") } } @@ -5022,6 +5036,35 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("Or use: multiclaude stop-all") } + // Ensure core agents exist for each repository + agentsCreated := 0 + workspacesCreated := 0 + for _, repoName := range st.ListRepos() { + if verbose { + fmt.Printf("\nEnsuring core agents for repository: %s\n", repoName) + } + + // Ensure core agents (supervisor, merge-queue/pr-shepherd) + created, err := c.ensureCoreAgents(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure core agents: %v\n", err) + } + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := c.ensureDefaultWorkspace(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure default workspace: %v\n", err) + } + } else if wsCreated { + workspacesCreated++ + } + } + // Save updated state if err := st.Save(); err != nil { return fmt.Errorf("failed to save repaired state: %w", err) @@ -5029,18 +5072,294 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("\nāœ“ Local repair completed") if agentsRemoved > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", agentsRemoved) + fmt.Printf(" Removed: %d dead agent(s)\n", agentsRemoved) } if issuesFixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", issuesFixed) + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", issuesFixed) + } + if agentsCreated > 0 { + fmt.Printf(" Created: %d core agent(s)\n", agentsCreated) + } + if workspacesCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", workspacesCreated) } - if agentsRemoved == 0 && issuesFixed == 0 { - fmt.Println(" No issues found") + if agentsRemoved == 0 && issuesFixed == 0 && agentsCreated == 0 && workspacesCreated == 0 { + fmt.Println(" No issues found, no changes needed") } return nil } +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns counts of agents created. +func (c *CLI) ensureCoreAgents(st *state.State, repoName string, verbose bool) (int, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return 0, fmt.Errorf("repository %s not found in state", repoName) + } + + created := 0 + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + tmuxClient := tmux.NewClient() + + // Check if session exists + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping core agent creation\n", tmuxSession) + } + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + if verbose { + fmt.Println(" Creating missing supervisor agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "supervisor", state.AgentTypeSupervisor, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + if verbose { + fmt.Println(" Creating missing pr-shepherd agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "pr-shepherd", state.AgentTypePRShepherd, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + if verbose { + fmt.Println(" Creating missing merge-queue agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "merge-queue", state.AgentTypeMergeQueue, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// createCoreAgent creates a core agent (supervisor, merge-queue, or pr-shepherd) +func (c *CLI) createCoreAgent(st *state.State, repo *state.Repository, repoName, repoPath, agentName string, agentType state.AgentType, tmuxClient *tmux.Client) error { + tmuxSession := repo.TmuxSession + + // Check if window already exists + hasWindow, _ := tmuxClient.HasWindow(context.Background(), tmuxSession, agentName) + if !hasWindow { + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", agentName, "-c", repoPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create tmux window: %w", err) + } + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + var promptFile string + switch agentType { + case state.AgentTypeSupervisor: + promptFile, err = c.writePromptFile(repoPath, state.AgentTypeSupervisor, agentName) + case state.AgentTypeMergeQueue: + mqConfig := repo.MergeQueueConfig + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + promptFile, err = c.writeMergeQueuePromptFile(repoPath, agentName, mqConfig) + case state.AgentTypePRShepherd: + psConfig := repo.PRShepherdConfig + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + promptFile, err = c.writePRShepherdPromptFile(repoPath, agentName, psConfig, repo.ForkConfig) + default: + return fmt.Errorf("unsupported agent type: %s", agentType) + } + if err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, repoPath); err != nil && agentType == state.AgentTypeSupervisor { + // Only warn for supervisor + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, agentName, repoPath, sessionID, promptFile, repoName, "") + if err != nil { + return fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, agentName, repoName, agentName, string(agentType)); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register agent with state + agent := state.Agent{ + Type: agentType, + WorktreePath: repoPath, + TmuxWindow: agentName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, agentName, agent); err != nil { + return fmt.Errorf("failed to add agent to state: %w", err) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (c *CLI) ensureDefaultWorkspace(st *state.State, repoName string, verbose bool) (bool, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Create default workspace + workspaceName := "my-default-2" + if verbose { + fmt.Printf(" Creating default workspace '%s'...\n", workspaceName) + } + + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + + // Check if session exists + tmuxClient := tmux.NewClient() + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping workspace creation\n", tmuxSession) + } + return false, nil + } + + // Create worktree + wt := worktree.NewManager(repoPath) + wtPath := c.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, workspaceName) + if err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return false, fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, workspaceName, wtPath, sessionID, promptFile, repoName, "") + if err != nil { + return false, fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, workspaceName, repoName, workspaceName, "workspace"); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil +} + // restartClaude restarts Claude in the current agent context. // It auto-detects whether to use --resume or --session-id based on session history. func (c *CLI) restartClaude(args []string) error { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9224905..810892c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -987,6 +987,271 @@ func TestCLIRepairCommand(t *testing.T) { } } +func TestRepairEnsuringCoreAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository to state + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify merge-queue was created (in non-fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created by repair") + } + + // Verify default workspace was created + hasWorkspace := false + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + if !hasWorkspace { + t.Errorf("Default workspace was not created by repair") + } +} + +func TestRepairEnsuringPRShepherdInForkMode(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository in fork mode + repoName := "test-fork-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-fork-repo" + + // Add repository to state (fork mode) + repo := &state.Repository{ + GithubURL: "https://github.com/test/fork", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{Enabled: false}, + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{ + IsFork: true, + UpstreamURL: "https://github.com/upstream/repo", + UpstreamOwner: "upstream", + UpstreamRepo: "repo", + }, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and pr-shepherd, NOT merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify pr-shepherd was created (in fork mode) + if _, exists := updatedRepo.Agents["pr-shepherd"]; !exists { + t.Errorf("PR-shepherd agent was not created by repair in fork mode") + } + + // Verify merge-queue was NOT created (fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; exists { + t.Errorf("Merge-queue agent should not be created in fork mode") + } +} + +func TestRepairDoesNotDuplicateAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository with existing supervisor + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + WorktreePath: repoPath, + TmuxWindow: "supervisor", + SessionID: "existing-session-id", + PID: 12345, + }, + "my-workspace": { + Type: state.AgentTypeWorkspace, + WorktreePath: cli.paths.AgentWorktree(repoName, "my-workspace"), + TmuxWindow: "my-workspace", + SessionID: "workspace-session-id", + PID: 12346, + }, + }, + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession, "-n", "supervisor").Run(); err != nil { + t.Skipf("Skipping test: tmux not available: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create workspace window + if err := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", "my-workspace").Run(); err != nil { + t.Fatalf("Failed to create workspace window: %v", err) + } + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Create workspace worktree directory + wsPath := cli.paths.AgentWorktree(repoName, "my-workspace") + if err := os.MkdirAll(wsPath, 0755); err != nil { + t.Fatalf("Failed to create workspace directory: %v", err) + } + + // Run repair + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor still exists with same session ID (not duplicated) + updatedRepo, _ := st.GetRepo(repoName) + supervisor, exists := updatedRepo.Agents["supervisor"] + if !exists { + t.Errorf("Supervisor agent was removed") + } else if supervisor.SessionID != "existing-session-id" { + t.Errorf("Supervisor was replaced instead of kept (session ID changed)") + } + + // Verify merge-queue was created (since it was missing) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created") + } + + // Verify default workspace was NOT created (since one already exists) + workspaceCount := 0 + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + workspaceCount++ + } + } + if workspaceCount != 1 { + t.Errorf("Expected 1 workspace, got %d", workspaceCount) + } + if _, exists := updatedRepo.Agents["my-default-2"]; exists { + t.Errorf("Default workspace should not be created when workspace already exists") + } +} + func TestCLIDocsCommand(t *testing.T) { cli, _, cleanup := setupTestEnvironment(t) defer cleanup() diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d64b918..3e8b49b 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1213,17 +1213,253 @@ func (d *Daemon) handleRepairState(req socket.Request) socket.Response { } } - d.logger.Info("State repair completed: %d agents removed, %d issues fixed", agentsRemoved, issuesFixed) + // Ensure core agents exist for each repository + agentsCreated := 0 + workspacesCreated := 0 + for _, repoName := range d.state.ListRepos() { + // Ensure core agents (supervisor, merge-queue/pr-shepherd) + created, err := d.ensureCoreAgents(repoName) + if err != nil { + d.logger.Warn("Failed to ensure core agents for %s: %v", repoName, err) + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := d.ensureDefaultWorkspace(repoName) + if err != nil { + d.logger.Warn("Failed to ensure default workspace for %s: %v", repoName, err) + } else if wsCreated { + workspacesCreated++ + } + } + + d.logger.Info("State repair completed: %d agents removed, %d issues fixed, %d agents created, %d workspaces created", + agentsRemoved, issuesFixed, agentsCreated, workspacesCreated) return socket.Response{ Success: true, Data: map[string]interface{}{ - "agents_removed": agentsRemoved, - "issues_fixed": issuesFixed, + "agents_removed": agentsRemoved, + "issues_fixed": issuesFixed, + "agents_created": agentsCreated, + "workspaces_created": workspacesCreated, }, } } +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns the count of agents created. +func (d *Daemon) ensureCoreAgents(repoName string) (int, error) { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return 0, fmt.Errorf("repository %s not found in state", repoName) + } + + created := 0 + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping core agent creation for %s", repo.TmuxSession, repoName) + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + d.logger.Info("Creating missing supervisor agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "supervisor", state.AgentTypeSupervisor); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + d.logger.Info("Creating missing pr-shepherd agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "pr-shepherd", state.AgentTypePRShepherd); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + d.logger.Info("Creating missing merge-queue agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "merge-queue", state.AgentTypeMergeQueue); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// spawnCoreAgent spawns a core agent (supervisor, merge-queue, or pr-shepherd) +func (d *Daemon) spawnCoreAgent(repoName, agentName string, agentType state.AgentType) error { + // This delegates to the existing spawnAgent logic used by the restart mechanism + // We'll use the socket handler internally + args := map[string]interface{}{ + "repo": repoName, + "agent": agentName, + "class": string(agentType), + } + + resp := d.handleRestartAgent(socket.Request{ + Command: "restart_agent", + Args: args, + }) + + if !resp.Success { + return fmt.Errorf("failed to spawn agent: %s", resp.Error) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (d *Daemon) ensureDefaultWorkspace(repoName string) (bool, error) { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping workspace creation for %s", repo.TmuxSession, repoName) + return false, nil + } + + // Create default workspace + workspaceName := "my-default-2" + d.logger.Info("Creating default workspace '%s' for %s", workspaceName, repoName) + + // We need to manually create the workspace + repoPath := d.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + wtPath := d.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + // Create worktree + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window using exec.Command + cmd := exec.Command("tmux", "new-window", "-d", "-t", repo.TmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptContent, err := prompts.GetPrompt(repoPath, state.AgentTypeWorkspace, "") + if err != nil { + return false, fmt.Errorf("failed to get workspace prompt: %w", err) + } + + promptFile := filepath.Join(d.paths.Root, "prompts", workspaceName+".md") + if err := os.MkdirAll(filepath.Dir(promptFile), 0755); err != nil { + return false, fmt.Errorf("failed to create prompts directory: %w", err) + } + if err := os.WriteFile(promptFile, []byte(promptContent), 0644); err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + d.logger.Warn("Failed to copy hooks config for workspace: %v", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + // Find Claude binary + claudeBinary, err := exec.LookPath("claude") + if err != nil { + return false, fmt.Errorf("failed to find claude binary: %w", err) + } + + // Build Claude command + claudeCmd := fmt.Sprintf("%s --session-id %s --dangerously-skip-permissions", claudeBinary, sessionID) + if promptFile != "" { + claudeCmd += fmt.Sprintf(" --append-system-prompt-file %s", promptFile) + } + + // Send command to tmux window + target := fmt.Sprintf("%s:%s", repo.TmuxSession, workspaceName) + cmd = exec.Command("tmux", "send-keys", "-t", target, claudeCmd, "C-m") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to start Claude in tmux: %w", err) + } + + // Wait for Claude to start + time.Sleep(500 * time.Millisecond) + + // Get PID + pid, err = d.tmux.GetPanePID(d.ctx, repo.TmuxSession, workspaceName) + if err != nil { + d.logger.Warn("Failed to get Claude PID for workspace: %v", err) + pid = 0 + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := d.state.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil +} + // handleGetRepoConfig returns the configuration for a repository func (d *Daemon) handleGetRepoConfig(req socket.Request) socket.Response { name, errResp, ok := getRequiredStringArg(req.Args, "name", "repository name is required")