diff --git a/.github/workflows/verify-docs.yml b/.github/workflows/verify-docs.yml new file mode 100644 index 0000000..c58a1a2 --- /dev/null +++ b/.github/workflows/verify-docs.yml @@ -0,0 +1,23 @@ +name: Verify Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Verify extension docs are in sync with code + run: go run ./cmd/verify-docs diff --git a/CLAUDE.md b/CLAUDE.md index d6e1fe4..bedc1e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,8 @@ MULTICLAUDE_TEST_MODE=1 go test ./test/... # Skip Claude startup │ Daemon (internal/daemon) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Health │ │ Message │ │ Wake/ │ │ Socket │ │ -│ │ Check │ │ Router │ │ Nudge │ │ Server │ │ -│ │ (2min) │ │ (2min) │ │ (2min) │ │ │ │ +│ │ Check │ │ (2min) │ │ (2min) │ │ Server │ │ +│ │ (2min) │ │ Router │ │ Nudge │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ └────────────────────────────────┬────────────────────────────────┘ │ @@ -212,6 +212,29 @@ External tools can integrate via: **Note:** Web UIs, event hooks, and notification systems are explicitly out of scope per ROADMAP.md. +### For LLMs: Keeping Extension Docs Updated + +**CRITICAL:** When modifying multiclaude core, keep extension documentation **code-first and drift-free**. Never document an API/command/event that does not exist on `main`. If something is planned, mark it `[PLANNED]` until it ships. Always run `go run ./cmd/verify-docs` on doc or API changes. + +1. **State Schema Changes** (`internal/state/state.go`) + - Update: [`docs/extending/STATE_FILE_INTEGRATION.md`](docs/extending/STATE_FILE_INTEGRATION.md) + - Update schema reference section + - Update all code examples showing state structure + - Run: `go run ./cmd/verify-docs` + +2. **Socket Command Changes** (`internal/daemon/daemon.go`) + - Update: [`docs/extending/SOCKET_API.md`](docs/extending/SOCKET_API.md) + - Add/update command reference entries + - Add code examples for new commands + - Update client library examples if needed + +**Pattern:** After any internal/* or pkg/* changes, search extension docs for outdated references: +```bash +# Find docs that might need updating +grep -r "internal/state" docs/extending/ +grep -r "socket.Request" docs/extending/ +``` + ## Contributing Checklist When modifying agent behavior: diff --git a/docs/CLI_RESTRUCTURE_PROPOSAL.md b/docs/CLI_RESTRUCTURE_PROPOSAL.md new file mode 100644 index 0000000..af88170 --- /dev/null +++ b/docs/CLI_RESTRUCTURE_PROPOSAL.md @@ -0,0 +1,147 @@ +# CLI Restructure Proposal + +> **Status**: Partially implemented (updated 2026-03-01) +> **Author**: cool-wolf worker +> **Aligns with**: ROADMAP P2 - Better onboarding + +## Implementation Status + +| Item | Status | PR | +|------|--------|-----| +| Add `Hidden` field to Command struct | DONE | #337 | +| Mark aliases as hidden from `--help` | DONE | #337 | +| Restructure help output with categories | DONE | #337 | +| Add `Category` field to Command struct | DONE | #337 | +| Add `guide` command | NOT STARTED | | +| Rename `agents` → `templates` | NOT STARTED | | +| Add deprecation warnings to aliases | NOT STARTED | | +| v2.0 alias removal plan | NOT STARTED | | +| Merge `worker`/`workspace` under `agent` | NOT STARTED | | +| Update COMMANDS.md | NOT STARTED | | +| Update embedded prompts | NOT STARTED | | + +--- + +## Problem Statement + +The multiclaude CLI has grown organically and now suffers from: + +1. **Too many top-level commands** (28 entries) +2. **Redundant aliases** that pollute `--help` output +3. **Inconsistent naming** (`agent`/`agents`, `work`/`worker`) +4. **Unclear mental model** - is the tool repo-centric or agent-centric? + +### Evidence: Current `--help` Output + +``` +Subcommands: + repair ← maintenance + claude ← agent context + logs ← agent ops + status ← system overview + daemon ← daemon management + worker ← agent creation + work ← ALIAS for worker + agent ← agent ops + attach ← ALIAS for agent attach + review ← agent creation + config ← repo config + start ← ALIAS for daemon start + list ← ALIAS for repo list + workspace ← agent creation + refresh ← agent ops + docs ← meta + diagnostics ← meta + version ← meta + agents ← agent definitions (confusing: singular vs plural) + init ← ALIAS for repo init + cleanup ← maintenance + bug ← meta + stop-all ← daemon control + repo ← repo management + history ← ALIAS for repo history + message ← agent comms +``` + +A new user sees 28 commands and has no idea where to start. + +## Proposed Solutions + +### Option D: Hybrid (Recommended) + +**Phase 1 (Partially Done)**: +1. ~~Hide aliases from `--help` (still work)~~ DONE +2. ~~Group help output by category~~ DONE +3. Add `multiclaude guide` command with interactive walkthrough +4. Rename `agents` → `templates` (avoids `agent`/`agents` confusion) + +**Phase 2 (v2.0)**: +1. Remove deprecated aliases +2. Optionally merge `worker`/`workspace` under `agent` + +## Remaining Actions + +### 1. Add `guide` Command + +```bash +$ multiclaude guide + +Welcome to multiclaude! Here's how to get started: + +1. INITIALIZE A REPO + multiclaude repo init https://github.com/you/repo + +2. START THE DAEMON + multiclaude start + +3. CREATE A WORKER + multiclaude worker "Fix the login bug" + +4. WATCH IT WORK + multiclaude agent attach + +Need more? See: multiclaude docs +``` + +### 2. Rename `agents` → `templates` + +The current naming creates confusion: +- `agent attach` - operate on running agent +- `agents list` - list agent definitions (templates) + +Rename to: +- `templates list` - list agent templates +- `templates spawn` - spawn from template +- `templates reset` - reset to defaults + +### 3. Add Deprecation Warnings + +Aliases should print notices pointing to canonical commands: + +| Current | Replacement | +|---------|-------------| +| `multiclaude init` | `multiclaude repo init` | +| `multiclaude list` | `multiclaude repo list` | +| `multiclaude start` | `multiclaude daemon start` | +| `multiclaude attach` | `multiclaude agent attach` | +| `multiclaude work` | `multiclaude worker` | +| `multiclaude history` | `multiclaude repo history` | + +### 4. Consider Merging `worker`/`workspace` under `agent` + +``` +multiclaude agent create "task" ← replaces worker create +multiclaude agent create --workspace ← replaces workspace add +multiclaude agent list ← unified listing +``` + +## Questions for Review + +1. **Keep `start` alias?** It's the most commonly used shortcut. +2. **Merge worker/workspace?** They're conceptually similar (agent instances). +3. **Add `quickstart` or `guide`?** Which name is clearer? +4. **Timeline for v2.0?** When do we remove deprecated aliases? + +--- + +*Originally generated by cool-wolf worker. Updated with implementation status.* diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1e88606..7b08c63 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,13 +1,22 @@ # Commands Reference -Everything you can tell multiclaude to do. +Everything you can tell multiclaude to do. Run `multiclaude` with no arguments for a quick reference. + +## Quick Start + +```bash +multiclaude repo init # Track a repository +multiclaude start # Start the daemon (alias for daemon start) +multiclaude worker "task" # Create a worker for a task +multiclaude status # See what's running +``` ## Daemon The daemon is the brain. Start it, and agents come alive. ```bash -multiclaude start # Wake up +multiclaude daemon start # Wake up multiclaude daemon stop # Go to sleep multiclaude daemon status # You alive? multiclaude daemon logs -f # What are you thinking? diff --git a/docs/extending/SOCKET_API.md b/docs/extending/SOCKET_API.md index 5d663a7..0461021 100644 --- a/docs/extending/SOCKET_API.md +++ b/docs/extending/SOCKET_API.md @@ -13,6 +13,7 @@ list_agents complete_agent restart_agent trigger_cleanup +trigger_refresh repair_state get_repo_config update_repo_config @@ -49,6 +50,7 @@ Each command below matches a `case` in `handleRequest`. | `complete_agent` | Mark agent ready for cleanup | `repo`, `name`, `summary`, `failure_reason` | | `restart_agent` | Restart a persistent agent | `repo`, `name` | | `trigger_cleanup` | Force cleanup cycle | none | +| `trigger_refresh` | Force worktree refresh cycle | none | | `repair_state` | Run state repair routine | none | | `get_repo_config` | Get merge-queue / pr-shepherd config | `repo` | | `update_repo_config` | Update repo config | `repo`, `config` (JSON object) | @@ -644,6 +646,27 @@ class MulticlaudeClient { } ``` +#### trigger_refresh + +**Description:** Trigger immediate worktree refresh for all agents (syncs with main branch) + +**Request:** +```json +{ + "command": "trigger_refresh" +} +``` + +**Response:** +```json +{ + "success": true, + "data": "Worktree refresh triggered" +} +``` + +**Note:** Refresh runs asynchronously in the background. + #### repair_state **Description:** Repair inconsistent state (equivalent to `multiclaude repair`) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..4b88474 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,10 +9,14 @@ import ( "os/exec" "path/filepath" "runtime/debug" + "sort" "strconv" "strings" + "syscall" "time" + "github.com/google/uuid" + "github.com/dlorenc/multiclaude/internal/agents" "github.com/dlorenc/multiclaude/internal/bugreport" "github.com/dlorenc/multiclaude/internal/daemon" @@ -73,11 +77,44 @@ func IsDevVersion() bool { // Command represents a CLI command type Command struct { - Name string - Description string - Usage string - Run func(args []string) error - Subcommands map[string]*Command + Name string + Description string + LongDescription string // Detailed help text shown with --help + Usage string + Run func(args []string) error + Subcommands map[string]*Command + Hidden bool // Don't show in --help (for aliases) + Category string // For grouping in help output +} + +// CommandSchema is a JSON-serializable representation of a command for LLM parsing +type CommandSchema struct { + Name string `json:"name"` + Description string `json:"description"` + Usage string `json:"usage,omitempty"` + Subcommands map[string]*CommandSchema `json:"subcommands,omitempty"` +} + +// toSchema converts a Command to its JSON-serializable schema +func (cmd *Command) toSchema() *CommandSchema { + schema := &CommandSchema{ + Name: cmd.Name, + Description: cmd.Description, + Usage: cmd.Usage, + } + + if len(cmd.Subcommands) > 0 { + schema.Subcommands = make(map[string]*CommandSchema) + for name, subcmd := range cmd.Subcommands { + // Skip internal commands (prefixed with _) + if strings.HasPrefix(name, "_") { + continue + } + schema.Subcommands[name] = subcmd.toSchema() + } + } + + return schema } // CLI manages the command-line interface @@ -203,7 +240,7 @@ func sanitizeTmuxSessionName(repoName string) string { // Execute executes the CLI with the given arguments func (c *CLI) Execute(args []string) error { if len(args) == 0 { - return c.showHelp() + return c.showHelp(false) } // Check for --version or -v flag at top level @@ -211,6 +248,18 @@ func (c *CLI) Execute(args []string) error { return c.showVersion() } + // Check for --help or -h with optional --json at top level + if args[0] == "--help" || args[0] == "-h" { + flags, _ := ParseFlags(args) + outputJSON := flags["json"] == "true" + return c.showHelp(outputJSON) + } + + // Check for --json alone (output full command tree) + if args[0] == "--json" { + return c.showHelp(true) + } + return c.executeCommand(c.rootCmd, args) } @@ -248,12 +297,19 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { if cmd.Run != nil { return cmd.Run([]string{}) } - return c.showCommandHelp(cmd) + return c.showCommandHelp(cmd, false) } - // Check for --help or -h flag + // Check for --help or -h flag with optional --json if args[0] == "--help" || args[0] == "-h" { - return c.showCommandHelp(cmd) + flags, _ := ParseFlags(args) + outputJSON := flags["json"] == "true" + return c.showCommandHelp(cmd, outputJSON) + } + + // Check for --json alone (output command schema) + if args[0] == "--json" { + return c.showCommandHelp(cmd, true) } // Check for subcommands @@ -270,24 +326,95 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { } // showHelp shows the main help message -func (c *CLI) showHelp() error { +func (c *CLI) showHelp(outputJSON bool) error { + if outputJSON { + schema := c.rootCmd.toSchema() + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(schema) + } + fmt.Println("multiclaude - repo-centric orchestrator for Claude Code") fmt.Println() - fmt.Println("Usage: multiclaude [options]") + fmt.Println("QUICK START:") + fmt.Println(" repo init Track a GitHub repository") + fmt.Println(" start Start the daemon") + fmt.Println(" worker \"task\" Create a worker for a task") + fmt.Println(" status See what's running") fmt.Println() - fmt.Println("Commands:") + + // Define category order and labels + categories := []struct { + key string + label string + }{ + {"daemon", "DAEMON:"}, + {"repo", "REPOSITORIES:"}, + {"agent", "AGENTS:"}, + {"comm", "COMMUNICATION:"}, + {"maint", "MAINTENANCE:"}, + {"meta", "META:"}, + } + + // Group commands by category + byCategory := make(map[string][]*struct { + name string + cmd *Command + }) for name, cmd := range c.rootCmd.Subcommands { - fmt.Printf(" %-15s %s\n", name, cmd.Description) + if cmd.Hidden || strings.HasPrefix(name, "_") { + continue + } + cat := cmd.Category + if cat == "" { + cat = "meta" // Default category + } + byCategory[cat] = append(byCategory[cat], &struct { + name string + cmd *Command + }{name, cmd}) } - fmt.Println() - fmt.Println("Use 'multiclaude --help' for more information about a command.") + // Sort commands within each category + for _, cmds := range byCategory { + sort.Slice(cmds, func(i, j int) bool { + return cmds[i].name < cmds[j].name + }) + } + + // Print by category + for _, cat := range categories { + cmds := byCategory[cat.key] + if len(cmds) == 0 { + continue + } + fmt.Println(cat.label) + for _, item := range cmds { + fmt.Printf(" %-15s %s\n", item.name, item.cmd.Description) + } + fmt.Println() + } + + fmt.Println("Run 'multiclaude --help' for details.") + fmt.Println("Use 'multiclaude --json' for machine-readable command tree (LLM-friendly).") return nil } // showCommandHelp shows help for a specific command -func (c *CLI) showCommandHelp(cmd *Command) error { +func (c *CLI) showCommandHelp(cmd *Command, outputJSON bool) error { + if outputJSON { + schema := cmd.toSchema() + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(schema) + } + + // If this is the root command, use the categorized help + if cmd == c.rootCmd { + return c.showHelp(false) + } + fmt.Printf("%s - %s\n", cmd.Name, cmd.Description) fmt.Println() if cmd.Usage != "" { @@ -295,11 +422,16 @@ func (c *CLI) showCommandHelp(cmd *Command) error { fmt.Println() } + if cmd.LongDescription != "" { + fmt.Println(cmd.LongDescription) + fmt.Println() + } + if len(cmd.Subcommands) > 0 { fmt.Println("Subcommands:") for name, subcmd := range cmd.Subcommands { - // Skip internal commands (prefixed with _) - if strings.HasPrefix(name, "_") { + // Skip internal commands (prefixed with _) and hidden commands + if strings.HasPrefix(name, "_") || subcmd.Hidden { continue } fmt.Printf(" %-15s %s\n", name, subcmd.Description) @@ -319,6 +451,8 @@ func (c *CLI) registerCommands() { Description: "Start the daemon (alias for 'daemon start')", Usage: "multiclaude start", Run: c.startDaemon, + Hidden: true, // Alias - prefer 'daemon start' + Category: "daemon", } // Root-level status command - comprehensive system overview @@ -327,12 +461,14 @@ func (c *CLI) registerCommands() { Description: "Show system status overview", Usage: "multiclaude status", Run: c.systemStatus, + Category: "maint", } daemonCmd := &Command{ Name: "daemon", Description: "Manage the multiclaude daemon", Subcommands: make(map[string]*Command), + Category: "daemon", } daemonCmd.Subcommands["start"] = &Command{ @@ -377,6 +513,7 @@ func (c *CLI) registerCommands() { Description: "Stop daemon and kill all multiclaude tmux sessions", Usage: "multiclaude stop-all [--clean] [--yes]", Run: c.stopAll, + Category: "daemon", } // Repository commands (repo subcommand) @@ -384,6 +521,7 @@ func (c *CLI) registerCommands() { Name: "repo", Description: "Manage repositories", Subcommands: make(map[string]*Command), + Category: "repo", } repoCmd.Subcommands["init"] = &Command{ @@ -438,16 +576,43 @@ func (c *CLI) registerCommands() { repoCmd.Subcommands["hibernate"] = &Command{ Name: "hibernate", Description: "Hibernate a repository, archiving uncommitted changes", - Usage: "multiclaude repo hibernate [--repo ] [--all] [--yes]", - Run: c.hibernateRepo, + LongDescription: `Hibernating a repository STOPS ALL TOKEN CONSUMPTION by killing agents. + +Running agents (supervisor, merge-queue, workspace, workers) continuously +consume API tokens even when idle. Use hibernate to pause billing. + +Options: + --repo Repository to hibernate (auto-detected if in worktree) + --all Also hibernate persistent agents (supervisor, workspace) + --yes Skip confirmation prompt + +By default, only workers and review agents are hibernated. Use --all to +stop ALL agents including supervisor, merge-queue, and workspace. + +Uncommitted changes are archived to ~/.multiclaude/archives// and +can be recovered later. + +Example: + multiclaude repo hibernate --all # Stop all agents, stop token usage + multiclaude repo hibernate # Stop workers only, core agents remain`, + Usage: "multiclaude repo hibernate [--repo ] [--all] [--yes]", + Run: c.hibernateRepo, } c.rootCmd.Subcommands["repo"] = repoCmd - // Backward compatibility aliases for root-level repo commands - c.rootCmd.Subcommands["init"] = repoCmd.Subcommands["init"] - c.rootCmd.Subcommands["list"] = repoCmd.Subcommands["list"] - c.rootCmd.Subcommands["history"] = repoCmd.Subcommands["history"] + // Backward compatibility aliases for root-level repo commands (hidden) + initAlias := *repoCmd.Subcommands["init"] + initAlias.Hidden = true + c.rootCmd.Subcommands["init"] = &initAlias + + listAlias := *repoCmd.Subcommands["list"] + listAlias.Hidden = true + c.rootCmd.Subcommands["list"] = &listAlias + + historyAlias := *repoCmd.Subcommands["history"] + historyAlias.Hidden = true + c.rootCmd.Subcommands["history"] = &historyAlias // Worker commands workerCmd := &Command{ @@ -455,6 +620,7 @@ func (c *CLI) registerCommands() { Description: "Manage worker agents", Usage: "multiclaude worker [] [--repo ] [--branch ] [--push-to ]", Subcommands: make(map[string]*Command), + Category: "agent", } workerCmd.Run = c.createWorker // Default action for 'worker' command (same as 'worker create') @@ -482,8 +648,10 @@ func (c *CLI) registerCommands() { c.rootCmd.Subcommands["worker"] = workerCmd - // 'work' is an alias for 'worker' (backward compatibility) - c.rootCmd.Subcommands["work"] = workerCmd + // 'work' is an alias for 'worker' (backward compatibility, hidden) + workAlias := *workerCmd + workAlias.Hidden = true + c.rootCmd.Subcommands["work"] = &workAlias // Workspace commands workspaceCmd := &Command{ @@ -491,6 +659,7 @@ func (c *CLI) registerCommands() { Description: "Manage workspaces", Usage: "multiclaude workspace []", Subcommands: make(map[string]*Command), + Category: "agent", } workspaceCmd.Run = c.workspaceDefault // Default action: list or connect @@ -530,6 +699,7 @@ func (c *CLI) registerCommands() { Name: "agent", Description: "Agent communication commands", Subcommands: make(map[string]*Command), + Category: "agent", } // Legacy message commands (aliases for backward compatibility) @@ -591,6 +761,7 @@ func (c *CLI) registerCommands() { Name: "message", Description: "Manage inter-agent messages", Subcommands: make(map[string]*Command), + Category: "comm", } messageCmd.Subcommands["send"] = &Command{ @@ -623,8 +794,10 @@ func (c *CLI) registerCommands() { c.rootCmd.Subcommands["message"] = messageCmd - // 'attach' is an alias for 'agent attach' (backward compatibility) - c.rootCmd.Subcommands["attach"] = agentCmd.Subcommands["attach"] + // 'attach' is an alias for 'agent attach' (backward compatibility, hidden) + attachAlias := *agentCmd.Subcommands["attach"] + attachAlias.Hidden = true + c.rootCmd.Subcommands["attach"] = &attachAlias // Maintenance commands c.rootCmd.Subcommands["cleanup"] = &Command{ @@ -632,6 +805,7 @@ func (c *CLI) registerCommands() { Description: "Clean up orphaned resources", Usage: "multiclaude cleanup [--dry-run] [--verbose] [--merged]", Run: c.cleanup, + Category: "maint", } c.rootCmd.Subcommands["repair"] = &Command{ @@ -639,13 +813,15 @@ func (c *CLI) registerCommands() { Description: "Repair state after crash", Usage: "multiclaude repair [--verbose]", Run: c.repair, + Category: "maint", } c.rootCmd.Subcommands["refresh"] = &Command{ Name: "refresh", Description: "Sync agent worktrees with main branch", - Usage: "multiclaude refresh", + Usage: "multiclaude refresh [--all]", Run: c.refresh, + Category: "maint", } // Claude restart command - for resuming Claude after exit @@ -654,6 +830,7 @@ func (c *CLI) registerCommands() { Description: "Restart Claude in current agent context", Usage: "multiclaude claude", Run: c.restartClaude, + Category: "agent", } // Debug command @@ -662,6 +839,7 @@ func (c *CLI) registerCommands() { Description: "Show generated CLI documentation", Usage: "multiclaude docs", Run: c.showDocs, + Category: "meta", } // Review command @@ -670,6 +848,7 @@ func (c *CLI) registerCommands() { Description: "Spawn a review agent for a PR", Usage: "multiclaude review ", Run: c.reviewPR, + Category: "agent", } // Logs commands @@ -678,6 +857,7 @@ func (c *CLI) registerCommands() { Description: "View and manage agent output logs", Usage: "multiclaude logs [] [-f|--follow]", Subcommands: make(map[string]*Command), + Category: "maint", } logsCmd.Run = c.viewLogs // Default action: view logs for an agent @@ -711,6 +891,7 @@ func (c *CLI) registerCommands() { Description: "View or modify repository configuration", Usage: "multiclaude config [repo] [--mq-enabled=true|false] [--mq-track=all|author|assigned] [--ps-enabled=true|false] [--ps-track=all|author|assigned]", Run: c.configRepo, + Category: "repo", } // Bug report command @@ -719,6 +900,7 @@ func (c *CLI) registerCommands() { Description: "Generate a diagnostic bug report", Usage: "multiclaude bug [--output ] [--verbose] [description]", Run: c.bugReport, + Category: "meta", } // Diagnostics command @@ -727,6 +909,7 @@ func (c *CLI) registerCommands() { Description: "Show system diagnostics in machine-readable format", Usage: "multiclaude diagnostics [--json] [--output ]", Run: c.diagnostics, + Category: "meta", } // Version command @@ -735,6 +918,7 @@ func (c *CLI) registerCommands() { Description: "Show version information", Usage: "multiclaude version [--json]", Run: c.versionCommand, + Category: "meta", } // Agents command - for managing agent definitions @@ -742,6 +926,7 @@ func (c *CLI) registerCommands() { Name: "agents", Description: "Manage agent definitions", Subcommands: make(map[string]*Command), + Category: "agent", } agentsCmd.Subcommands["list"] = &Command{ @@ -891,6 +1076,9 @@ func (c *CLI) systemStatus(args []string) error { fmt.Printf(" Repos: %d\n", len(repos)) fmt.Println() + // Track total active agents for token warning + totalActiveAgents := 0 + // Show each repo with agents for _, repo := range repos { repoMap, ok := repo.(map[string]interface{}) @@ -903,10 +1091,8 @@ func (c *CLI) systemStatus(args []string) error { if v, ok := repoMap["total_agents"].(float64); ok { totalAgents = int(v) } - workerCount := 0 - if v, ok := repoMap["worker_count"].(float64); ok { - workerCount = int(v) - } + totalActiveAgents += totalAgents + sessionHealthy, _ := repoMap["session_healthy"].(bool) // Repo line @@ -916,12 +1102,31 @@ func (c *CLI) systemStatus(args []string) error { } fmt.Printf(" %s %s\n", repoStatus, format.Bold.Sprint(name)) - // Agent summary - coreAgents := totalAgents - workerCount - if coreAgents < 0 { - coreAgents = 0 + // Show core agents by name and type + if coreAgents, ok := repoMap["core_agents"].([]interface{}); ok && len(coreAgents) > 0 { + var coreNames []string + for _, ca := range coreAgents { + if caMap, ok := ca.(map[string]interface{}); ok { + agentName, _ := caMap["name"].(string) + agentType, _ := caMap["type"].(string) + coreNames = append(coreNames, fmt.Sprintf("%s (%s)", agentName, agentType)) + } + } + fmt.Printf(" Core: %s\n", strings.Join(coreNames, ", ")) + } + + // Show workers + if workerNames, ok := repoMap["worker_names"].([]interface{}); ok && len(workerNames) > 0 { + var names []string + for _, wn := range workerNames { + if name, ok := wn.(string); ok { + names = append(names, name) + } + } + fmt.Printf(" Workers: %s\n", strings.Join(names, ", ")) + } else { + fmt.Printf(" Workers: none\n") } - fmt.Printf(" Agents: %d core, %d workers\n", coreAgents, workerCount) // Show fork info if applicable if isFork, _ := repoMap["is_fork"].(bool); isFork { @@ -934,6 +1139,16 @@ func (c *CLI) systemStatus(args []string) error { } fmt.Println() + + // Token consumption warning + if totalActiveAgents > 0 { + fmt.Printf(" %s %d active agent(s) consuming API tokens\n", + format.Yellow.Sprint("⚠"), + totalActiveAgents) + format.Dimmed(" Stop token usage: multiclaude repo hibernate --all") + fmt.Println() + } + format.Dimmed("Details: multiclaude repo list | multiclaude worker list") return nil } @@ -1226,16 +1441,16 @@ func (c *CLI) initRepo(args []string) error { // Check if repository is already initialized st, err := state.Load(c.paths.StateFile) if err != nil { - return fmt.Errorf("failed to load state: %w", err) + return errors.StateLoadFailed(err) } if _, exists := st.GetRepo(repoName); exists { - return fmt.Errorf("repository '%s' is already initialized\nUse 'multiclaude repo rm %s' to remove it first, or choose a different name", repoName, repoName) + return errors.RepoAlreadyExists(repoName) } // Check if tmux session already exists (stale session from previous incomplete init) tmuxSession := sanitizeTmuxSessionName(repoName) if tmuxSession == "mc-" { - return fmt.Errorf("invalid tmux session name: repository name cannot be empty") + return errors.InvalidTmuxSessionName("repository name cannot be empty") } tmuxClient := tmux.NewClient() if exists, err := tmuxClient.HasSession(context.Background(), tmuxSession); err == nil && exists { @@ -1243,7 +1458,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Printf("This may be from a previous incomplete initialization.\n") fmt.Printf("Auto-repairing: killing existing tmux session...\n") if err := tmuxClient.KillSession(context.Background(), tmuxSession); err != nil { - return fmt.Errorf("failed to clean up existing tmux session: %w\nPlease manually kill it with: tmux kill-session -t %s", err, tmuxSession) + return errors.TmuxSessionCleanupNeeded(tmuxSession, err) } fmt.Println("✓ Cleaned up stale tmux session") } @@ -1251,7 +1466,7 @@ func (c *CLI) initRepo(args []string) error { // Check if repository directory already exists repoPath := c.paths.RepoDir(repoName) if _, err := os.Stat(repoPath); err == nil { - return fmt.Errorf("directory already exists: %s\nRemove it manually or choose a different name", repoPath) + return errors.DirectoryAlreadyExists(repoPath) } // Clone repository @@ -1331,38 +1546,38 @@ func (c *CLI) initRepo(args []string) error { // Generate session IDs for agents supervisorSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate supervisor session ID: %w", err) + return errors.SessionIDGenerationFailed("supervisor", err) } var mergeQueueSessionID, prShepherdSessionID string if mqEnabled { mergeQueueSessionID, err = claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate merge-queue session ID: %w", err) + return errors.SessionIDGenerationFailed("merge-queue", err) } } else if psEnabled { prShepherdSessionID, err = claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate pr-shepherd session ID: %w", err) + return errors.SessionIDGenerationFailed("pr-shepherd", err) } } // Write prompt files supervisorPromptFile, err := c.writePromptFile(repoPath, state.AgentTypeSupervisor, "supervisor") if err != nil { - return fmt.Errorf("failed to write supervisor prompt: %w", err) + return errors.PromptWriteFailed("supervisor", err) } var mergeQueuePromptFile, prShepherdPromptFile string if mqEnabled { mergeQueuePromptFile, err = c.writeMergeQueuePromptFile(repoPath, "merge-queue", mqConfig) if err != nil { - return fmt.Errorf("failed to write merge-queue prompt: %w", err) + return errors.PromptWriteFailed("merge-queue", err) } } else if psEnabled { prShepherdPromptFile, err = c.writePRShepherdPromptFile(repoPath, "pr-shepherd", psConfig, forkConfig) if err != nil { - return fmt.Errorf("failed to write pr-shepherd prompt: %w", err) + return errors.PromptWriteFailed("pr-shepherd", err) } } @@ -1377,13 +1592,13 @@ func (c *CLI) initRepo(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in supervisor window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, "supervisor", repoPath, supervisorSessionID, supervisorPromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start supervisor Claude: %w", err) + return errors.ClaudeStartFailed("supervisor", err) } supervisorPID = pid @@ -1397,7 +1612,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Println("Starting Claude Code in merge-queue window...") pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, "merge-queue", repoPath, mergeQueueSessionID, mergeQueuePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start merge-queue Claude: %w", err) + return errors.ClaudeStartFailed("merge-queue", err) } mergeQueuePID = pid @@ -1409,7 +1624,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Println("Starting Claude Code in pr-shepherd window...") pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, "pr-shepherd", repoPath, prShepherdSessionID, prShepherdPromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start pr-shepherd Claude: %w", err) + return errors.ClaudeStartFailed("pr-shepherd", err) } prShepherdPID = pid @@ -1441,10 +1656,10 @@ func (c *CLI) initRepo(args []string) error { Args: addRepoArgs, }) if err != nil { - return fmt.Errorf("failed to register repository with daemon: %w", err) + return errors.AgentRegistrationFailed("repository", err) } if !resp.Success { - return fmt.Errorf("failed to register repository: %s", resp.Error) + return errors.AgentRegistrationFailed("repository", fmt.Errorf("%s", resp.Error)) } // Add supervisor agent @@ -1461,10 +1676,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register supervisor: %w", err) + return errors.AgentRegistrationFailed("supervisor", err) } if !resp.Success { - return fmt.Errorf("failed to register supervisor: %s", resp.Error) + return errors.AgentRegistrationFailed("supervisor", fmt.Errorf("%s", resp.Error)) } // Add merge-queue agent only if enabled (non-fork mode) @@ -1482,10 +1697,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register merge-queue: %w", err) + return errors.AgentRegistrationFailed("merge-queue", err) } if !resp.Success { - return fmt.Errorf("failed to register merge-queue: %s", resp.Error) + return errors.AgentRegistrationFailed("merge-queue", fmt.Errorf("%s", resp.Error)) } } @@ -1504,10 +1719,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register pr-shepherd: %w", err) + return errors.AgentRegistrationFailed("pr-shepherd", err) } if !resp.Success { - return fmt.Errorf("failed to register pr-shepherd: %s", resp.Error) + return errors.AgentRegistrationFailed("pr-shepherd", fmt.Errorf("%s", resp.Error)) } } @@ -1522,9 +1737,9 @@ func (c *CLI) initRepo(args []string) error { // Check if it's a conflict state that requires manual resolution hasConflict, suggestion, checkErr := wt.CheckWorkspaceBranchConflict() if checkErr == nil && hasConflict { - return fmt.Errorf("workspace branch conflict detected:\n%s", suggestion) + return errors.New(errors.CategoryConfig, fmt.Sprintf("workspace branch conflict detected:\n%s", suggestion)) } - return fmt.Errorf("failed to check workspace branch state: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to check workspace branch state", err) } if migrated { fmt.Println("Migrated legacy 'workspace' branch to 'workspace/default'") @@ -1533,25 +1748,25 @@ func (c *CLI) initRepo(args []string) error { fmt.Printf("Creating default workspace worktree at: %s\n", workspacePath) if err := wt.CreateNewBranch(workspacePath, workspaceBranch, "HEAD"); err != nil { - return fmt.Errorf("failed to create default workspace worktree: %w", err) + return errors.WorktreeCreationFailed(err) } // Create default workspace tmux window (detached so it doesn't switch focus) cmd = exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", "default", "-c", workspacePath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create workspace window: %w", err) + return errors.TmuxOperationFailed("create window", err) } // Generate session ID for workspace workspaceSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate workspace session ID: %w", err) + return errors.SessionIDGenerationFailed("workspace", err) } // Write prompt file for default workspace workspacePromptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, "default") if err != nil { - return fmt.Errorf("failed to write default workspace prompt: %w", err) + return errors.PromptWriteFailed("workspace", err) } // Copy hooks configuration if it exists @@ -1565,13 +1780,13 @@ func (c *CLI) initRepo(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in default workspace window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, "default", workspacePath, workspaceSessionID, workspacePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start default workspace Claude: %w", err) + return errors.ClaudeStartFailed("default workspace", err) } workspacePID = pid @@ -1595,10 +1810,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register default workspace: %w", err) + return errors.AgentRegistrationFailed("default workspace", err) } if !resp.Success { - return fmt.Errorf("failed to register default workspace: %s", resp.Error) + return errors.AgentRegistrationFailed("default workspace", fmt.Errorf("%s", resp.Error)) } fmt.Println() @@ -2104,7 +2319,7 @@ func (c *CLI) createWorker(args []string) error { if hasPushTo { // --push-to requires --branch to specify the remote branch to start from if _, hasBranch := flags["branch"]; !hasBranch { - return errors.InvalidUsage("--push-to requires --branch to specify the remote branch (e.g., --branch origin/work/jolly-hawk --push-to work/jolly-hawk)") + return errors.InvalidUsage("--push-to requires --branch to specify the remote branch (e.g., --branch origin/multiclaude/jolly-hawk --push-to multiclaude/jolly-hawk)") } } @@ -2175,7 +2390,7 @@ func (c *CLI) createWorker(args []string) error { } } else { // Normal case: create a new branch for this worker - branchName = fmt.Sprintf("work/%s", workerName) + branchName = fmt.Sprintf("multiclaude/%s", workerName) fmt.Printf("Creating worktree at: %s\n", wtPath) if err := wt.CreateNewBranch(wtPath, branchName, startBranch); err != nil { return errors.WorktreeCreationFailed(err) @@ -2224,7 +2439,7 @@ func (c *CLI) createWorker(args []string) error { // Generate session ID for worker workerSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate worker session ID: %w", err) + return errors.SessionIDGenerationFailed("worker", err) } // Get fork config from daemon to include in worker prompt @@ -2255,7 +2470,7 @@ func (c *CLI) createWorker(args []string) error { } workerPromptFile, err := c.writeWorkerPromptFile(repoPath, workerName, workerConfig) if err != nil { - return fmt.Errorf("failed to write worker prompt: %w", err) + return errors.PromptWriteFailed("worker", err) } // Copy hooks configuration if it exists @@ -2269,14 +2484,14 @@ func (c *CLI) createWorker(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in worker window...") initialMessage := fmt.Sprintf("Task: %s", task) pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, workerName, wtPath, workerSessionID, workerPromptFile, repoName, initialMessage) if err != nil { - return fmt.Errorf("failed to start worker Claude: %w", err) + return errors.ClaudeStartFailed("worker", err) } workerPID = pid @@ -2301,10 +2516,10 @@ func (c *CLI) createWorker(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register worker: %w", err) + return errors.AgentRegistrationFailed("worker", err) } if !resp.Success { - return fmt.Errorf("failed to register worker: %s", resp.Error) + return errors.AgentRegistrationFailed("worker", fmt.Errorf("%s", resp.Error)) } fmt.Println() @@ -3297,7 +3512,7 @@ func (c *CLI) addWorkspace(args []string) error { agentType, _ := agentMap["type"].(string) name, _ := agentMap["name"].(string) if agentType == "workspace" && name == workspaceName { - return fmt.Errorf("workspace '%s' already exists in repo '%s'", workspaceName, repoName) + return errors.WorkspaceAlreadyExists(workspaceName, repoName) } } } @@ -3316,7 +3531,7 @@ func (c *CLI) addWorkspace(args []string) error { fmt.Printf("This may be from a previous incomplete workspace creation.\n") fmt.Printf("Auto-repairing: removing existing worktree...\n") if err := wt.Remove(wtPath, true); err != nil { - return fmt.Errorf("failed to clean up existing worktree: %w\nPlease manually remove it with: git worktree remove %s", err, wtPath) + return errors.WorktreeCleanupNeeded(wtPath, err) } fmt.Println("✓ Cleaned up stale worktree") } @@ -3336,7 +3551,7 @@ func (c *CLI) addWorkspace(args []string) error { fmt.Printf("This may be from a previous incomplete workspace creation.\n") fmt.Printf("Auto-repairing: killing existing tmux window...\n") if err := tmuxClient.KillWindow(context.Background(), tmuxSession, workspaceName); err != nil { - return fmt.Errorf("failed to clean up existing tmux window: %w\nPlease manually kill it with: tmux kill-window -t %s:%s", err, tmuxSession, workspaceName) + return errors.TmuxWindowCleanupNeeded(tmuxSession, workspaceName, err) } fmt.Println("✓ Cleaned up stale tmux window") } @@ -3351,13 +3566,13 @@ func (c *CLI) addWorkspace(args []string) error { // Generate session ID for workspace workspaceSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate workspace session ID: %w", err) + return errors.SessionIDGenerationFailed("workspace", err) } // Write prompt file for workspace workspacePromptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, workspaceName) if err != nil { - return fmt.Errorf("failed to write workspace prompt: %w", err) + return errors.PromptWriteFailed("workspace", err) } // Copy hooks configuration if it exists @@ -3371,13 +3586,13 @@ func (c *CLI) addWorkspace(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in workspace window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, workspaceName, wtPath, workspaceSessionID, workspacePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start workspace Claude: %w", err) + return errors.ClaudeStartFailed("workspace", err) } workspacePID = pid @@ -3401,10 +3616,10 @@ func (c *CLI) addWorkspace(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register workspace: %w", err) + return errors.AgentRegistrationFailed("workspace", err) } if !resp.Success { - return fmt.Errorf("failed to register workspace: %s", resp.Error) + return errors.AgentRegistrationFailed("workspace", fmt.Errorf("%s", resp.Error)) } fmt.Println() @@ -3713,7 +3928,7 @@ func (c *CLI) connectWorkspace(args []string) error { // validateWorkspaceName validates that a workspace name follows branch name restrictions func validateWorkspaceName(name string) error { if name == "" { - return fmt.Errorf("workspace name cannot be empty") + return errors.InvalidWorkspaceName(name, "cannot be empty") } // Git branch name restrictions @@ -3724,25 +3939,25 @@ func validateWorkspaceName(name string) error { // - Cannot be "." or ".." if name == "." || name == ".." { - return fmt.Errorf("workspace name cannot be '.' or '..'") + return errors.InvalidWorkspaceName(name, "cannot be '.' or '..'") } if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "-") { - return fmt.Errorf("workspace name cannot start with '.' or '-'") + return errors.InvalidWorkspaceName(name, "cannot start with '.' or '-'") } if strings.HasSuffix(name, ".") || strings.HasSuffix(name, "/") { - return fmt.Errorf("workspace name cannot end with '.' or '/'") + return errors.InvalidWorkspaceName(name, "cannot end with '.' or '/'") } if strings.Contains(name, "..") { - return fmt.Errorf("workspace name cannot contain '..'") + return errors.InvalidWorkspaceName(name, "cannot contain '..'") } invalidChars := []string{"\\", "~", "^", ":", "?", "*", "[", "@", "{", "}", " ", "\t", "\n"} for _, char := range invalidChars { if strings.Contains(name, char) { - return fmt.Errorf("workspace name cannot contain '%s'", char) + return errors.InvalidWorkspaceName(name, fmt.Sprintf("cannot contain '%s'", char)) } } @@ -4066,7 +4281,7 @@ func (c *CLI) resolveRepo(flags map[string]string) (string, error) { } } - return "", fmt.Errorf("could not determine repository; use --repo flag or run 'multiclaude repo use '") + return "", errors.NoDefaultRepo() } // inferAgentContext infers the current agent and repo from working directory @@ -4386,19 +4601,19 @@ func (c *CLI) reviewPR(args []string) error { fmt.Printf("Creating tmux window: %s\n", reviewerName) cmd = exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", reviewerName, "-c", wtPath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create tmux window: %w", err) + return errors.TmuxOperationFailed("create window", err) } // Generate session ID for reviewer reviewerSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate reviewer session ID: %w", err) + return errors.SessionIDGenerationFailed("reviewer", err) } // Write prompt file for reviewer reviewerPromptFile, err := c.writePromptFile(repoPath, state.AgentTypeReview, reviewerName) if err != nil { - return fmt.Errorf("failed to write reviewer prompt: %w", err) + return errors.PromptWriteFailed("reviewer", err) } // Copy hooks configuration if it exists @@ -4412,14 +4627,14 @@ func (c *CLI) reviewPR(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in reviewer window...") initialMessage := fmt.Sprintf("Review PR #%s: https://github.com/%s/%s/pull/%s", prNumber, parts[1], parts[2], prNumber) pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, reviewerName, wtPath, reviewerSessionID, reviewerPromptFile, repoName, initialMessage) if err != nil { - return fmt.Errorf("failed to start reviewer Claude: %w", err) + return errors.ClaudeStartFailed("reviewer", err) } reviewerPID = pid @@ -4445,10 +4660,10 @@ func (c *CLI) reviewPR(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register reviewer: %w", err) + return errors.AgentRegistrationFailed("reviewer", err) } if !resp.Success { - return fmt.Errorf("failed to register reviewer: %s", resp.Error) + return errors.AgentRegistrationFailed("reviewer", fmt.Errorf("%s", resp.Error)) } fmt.Println() @@ -4498,7 +4713,7 @@ func (c *CLI) viewLogs(args []string) error { } else if _, err := os.Stat(systemLogFile); err == nil { logFile = systemLogFile } else { - return fmt.Errorf("no log file found for agent %s in repo %s", agentName, repoName) + return errors.LogFileNotFound(agentName, repoName) } // Check for --follow flag @@ -4666,13 +4881,13 @@ func (c *CLI) cleanLogs(args []string) error { olderThan, ok := flags["older-than"] if !ok { - return fmt.Errorf("usage: multiclaude logs clean --older-than (e.g., 7d, 24h)") + return errors.InvalidUsage("usage: multiclaude logs clean --older-than (e.g., 7d, 24h)") } // Parse duration duration, err := parseDuration(olderThan) if err != nil { - return fmt.Errorf("invalid duration: %v", err) + return errors.InvalidDuration(olderThan) } cutoff := time.Now().Add(-duration) @@ -5163,8 +5378,12 @@ func (c *CLI) localCleanup(dryRun bool, verbose bool) error { } } - // Clean up orphaned work/* and workspace/* branches - removed, issues := c.cleanupOrphanedBranchesWithPrefix(wt, "work/", repoName, dryRun, verbose) + // Clean up orphaned multiclaude/*, work/* (legacy), and workspace/* branches + removed, issues := c.cleanupOrphanedBranchesWithPrefix(wt, "multiclaude/", repoName, dryRun, verbose) + totalRemoved += removed + totalIssues += issues + + removed, issues = c.cleanupOrphanedBranchesWithPrefix(wt, "work/", repoName, dryRun, verbose) totalRemoved += removed totalIssues += issues @@ -5289,19 +5508,144 @@ 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, ok := data["issues_fixed"].(float64); ok && fixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", int(fixed)) + 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 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") } } return nil } -// refresh triggers an immediate worktree sync for all agents +// refresh syncs worktrees with main branch. +// When run inside an agent worktree, refreshes just that worktree directly. +// When run outside an agent context (or with --all), triggers global refresh via daemon. func (c *CLI) refresh(args []string) error { + flags, _ := ParseFlags(args) + refreshAll := flags["all"] == "true" + + // If --all not specified, try to detect agent context + if !refreshAll { + cwd, err := os.Getwd() + if err == nil { + // Resolve symlinks for proper path comparison + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { + cwd = resolved + } + + // Check if we're in a worktree path: ~/.multiclaude/wts// + if hasPathPrefix(cwd, c.paths.WorktreesDir) { + rel, err := filepath.Rel(c.paths.WorktreesDir, cwd) + if err == nil { + parts := strings.SplitN(rel, string(filepath.Separator), 2) + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + return c.refreshAgentWorktree(repoName, agentName, cwd) + } + } + } + } + } + + // Global refresh via daemon + return c.refreshAllWorktrees() +} + +// refreshAgentWorktree refreshes a single agent's worktree directly +func (c *CLI) refreshAgentWorktree(repoName, agentName, wtPath string) error { + fmt.Printf("Refreshing worktree for %s/%s...\n", repoName, agentName) + + // Get the repo path to determine remote/branch + repoPath := c.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + + // Get remote and main branch + remote, err := wt.GetUpstreamRemote() + if err != nil { + return fmt.Errorf("failed to get remote: %w", err) + } + + mainBranch, err := wt.GetDefaultBranch(remote) + if err != nil { + return fmt.Errorf("failed to get default branch: %w", err) + } + + // Fetch latest from remote + fmt.Printf("Fetching from %s...\n", remote) + if err := wt.FetchRemote(remote); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + + // Check worktree state + wtState, err := worktree.GetWorktreeState(wtPath, remote, mainBranch) + if err != nil { + return fmt.Errorf("failed to get worktree state: %w", err) + } + + if !wtState.CanRefresh { + fmt.Printf("✓ No refresh needed: %s\n", wtState.RefreshReason) + return nil + } + + if wtState.CommitsBehind == 0 { + fmt.Println("✓ Already up to date") + return nil + } + + fmt.Printf("Rebasing onto %s/%s (%d commits behind)...\n", remote, mainBranch, wtState.CommitsBehind) + + // Perform the refresh + result := worktree.RefreshWorktree(wtPath, remote, mainBranch) + + if result.Error != nil { + if result.HasConflicts { + fmt.Println("\n⚠ Rebase has conflicts in:") + for _, f := range result.ConflictFiles { + fmt.Printf(" - %s\n", f) + } + fmt.Println("\nResolve conflicts and run 'git rebase --continue', or 'git rebase --abort' to cancel.") + return fmt.Errorf("rebase conflicts") + } + return fmt.Errorf("refresh failed: %w", result.Error) + } + + if result.Skipped { + fmt.Printf("✓ Skipped: %s\n", result.SkipReason) + return nil + } + + fmt.Printf("✓ Successfully rebased %d commits\n", result.CommitsRebased) + if result.WasStashed { + if result.StashRestored { + fmt.Println(" (uncommitted changes were stashed and restored)") + } else { + fmt.Println(" ⚠ Warning: uncommitted changes were stashed but could not be restored") + fmt.Println(" Run 'git stash pop' to restore them manually") + } + } + + return nil +} + +// refreshAllWorktrees triggers a global refresh via the daemon +func (c *CLI) refreshAllWorktrees() error { // Connect to daemon client := socket.NewClient(c.paths.DaemonSock) _, err := client.Send(socket.Request{Command: "ping"}) @@ -5309,7 +5653,7 @@ func (c *CLI) refresh(args []string) error { return errors.DaemonNotRunning() } - fmt.Println("Triggering worktree refresh...") + fmt.Println("Triggering worktree refresh for all agents...") resp, err := client.Send(socket.Request{ Command: "trigger_refresh", @@ -5473,6 +5817,40 @@ 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 + var repairErrors []string + 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 { + msg := fmt.Sprintf("repo %s: failed to ensure core agents: %v", repoName, err) + repairErrors = append(repairErrors, msg) + if verbose { + fmt.Printf(" Warning: %s\n", msg) + } + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := c.ensureDefaultWorkspace(st, repoName, verbose) + if err != nil { + msg := fmt.Sprintf("repo %s: failed to ensure default workspace: %v", repoName, err) + repairErrors = append(repairErrors, msg) + if verbose { + fmt.Printf(" Warning: %s\n", msg) + } + } else if wsCreated { + workspacesCreated++ + } + } + // Save updated state if err := st.Save(); err != nil { return fmt.Errorf("failed to save repaired state: %w", err) @@ -5480,19 +5858,252 @@ 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 && agentsCreated == 0 && workspacesCreated == 0 { + fmt.Println(" No issues found, no changes needed") + } + + if len(repairErrors) > 0 { + return fmt.Errorf("repair completed with %d error(s): %s", len(repairErrors), strings.Join(repairErrors, "; ")) + } + + return nil +} + +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns counts of agents created. +// This is the offline fallback; when the daemon is running, repair delegates to it instead. +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) + } + + repoPath := c.paths.RepoDir(repoName) + tmuxClient := tmux.NewClient() + + // Check if session exists + hasSession, err := tmuxClient.HasSession(context.Background(), repo.TmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping core agent creation\n", repo.TmuxSession) + } + return 0, nil + } + + // Use shared logic to determine which agents are missing + missing := state.MissingCoreAgents(repo) + created := 0 + + for _, spec := range missing { + if verbose { + fmt.Printf(" Creating missing %s agent...\n", spec.Name) + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, spec.Name, spec.Type, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create %s: %w", spec.Name, 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 agentsRemoved == 0 && issuesFixed == 0 { - fmt.Println(" No issues found") + + 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) + } + + if repo.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 checks if Claude is already running and provides helpful error messages if so. // It auto-detects whether to use --resume or --session-id based on session history. func (c *CLI) restartClaude(args []string) error { // Infer agent context from cwd @@ -5516,6 +6127,41 @@ func (c *CLI) restartClaude(args []string) error { return fmt.Errorf("agent has no session ID - try removing and recreating the agent") } + // Check if Claude is already running + if agent.PID > 0 { + // Check if the process is still alive + process, err := os.FindProcess(agent.PID) + if err == nil { + // Send signal 0 to check if process exists (doesn't actually signal, just checks) + err = process.Signal(syscall.Signal(0)) + if err == nil { + // Process is still running - provide helpful error + return fmt.Errorf("claude is already running (PID %d) in this context.\n\nTo restart:\n 1. Exit Claude first (Ctrl+D or /exit)\n 2. Then run 'multiclaude claude' again\n\nOr attach to the running session:\n multiclaude attach %s", agent.PID, agentName) + } + } + } + + // Get repo for tmux session info + repo, exists := st.GetRepo(repoName) + if !exists { + return fmt.Errorf("repo '%s' not found in state", repoName) + } + + // Double-check: get the current PID in the tmux pane to detect any running process + tmuxClient := tmux.NewClient() + currentPID, err := tmuxClient.GetPanePID(context.Background(), repo.TmuxSession, agent.TmuxWindow) + if err == nil && currentPID > 0 { + // Check if this PID is alive and different from what we checked above + if currentPID != agent.PID { + if process, err := os.FindProcess(currentPID); err == nil { + if err := process.Signal(syscall.Signal(0)); err == nil { + // There's a different running process in the pane + return fmt.Errorf("a process (PID %d) is already running in this tmux pane.\n\nTo restart:\n 1. Exit the current process first\n 2. Then run 'multiclaude claude' again\n\nOr attach to view:\n multiclaude attach %s", currentPID, agentName) + } + } + } + } + // Get the prompt file path (stored as ~/.multiclaude/prompts/.md) promptFile := filepath.Join(c.paths.Root, "prompts", agentName+".md") @@ -5538,14 +6184,25 @@ func (c *CLI) restartClaude(args []string) error { // Build the command var cmdArgs []string + sessionID := agent.SessionID if hasHistory { // Session has history - use --resume to continue - cmdArgs = []string{"--resume", agent.SessionID} - fmt.Printf("Resuming Claude session %s...\n", agent.SessionID) + cmdArgs = []string{"--resume", sessionID} + fmt.Printf("Resuming Claude session %s...\n", sessionID) } else { - // New session - use --session-id - cmdArgs = []string{"--session-id", agent.SessionID} - fmt.Printf("Starting new Claude session %s...\n", agent.SessionID) + // No history - generate a new session ID to avoid "already in use" errors + // This can happen when Claude exits abnormally or the previous session + // was started but never used + sessionID = uuid.New().String() + cmdArgs = []string{"--session-id", sessionID} + fmt.Printf("Starting new Claude session %s...\n", sessionID) + + // Update agent with new session ID + agent.SessionID = sessionID + if err := st.UpdateAgent(repoName, agentName, agent); err != nil { + fmt.Printf("Warning: failed to save new session ID: %v\n", err) + // Continue anyway - the session will work, just won't persist + } } // Add common flags @@ -5574,7 +6231,11 @@ func (c *CLI) showDocs(args []string) error { return nil } -// GenerateDocumentation generates markdown documentation for all CLI commands +// GenerateDocumentation generates markdown documentation for all CLI commands. +// NOTE: This markdown is injected into agent prompts. When adding or changing +// commands/flags that affect extension surfaces, ensure docs/extending/SOCKET_API.md +// and docs/extending/STATE_FILE_INTEGRATION.md stay accurate and rerun +// `go run ./cmd/verify-docs` so downstream tools/LLMs stay current. func (c *CLI) GenerateDocumentation() string { var sb strings.Builder diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 498577d..2293874 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/dlorenc/multiclaude/internal/daemon" + "github.com/dlorenc/multiclaude/internal/errors" "github.com/dlorenc/multiclaude/internal/messages" "github.com/dlorenc/multiclaude/internal/socket" "github.com/dlorenc/multiclaude/internal/state" @@ -1034,6 +1035,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() @@ -1628,6 +1894,53 @@ func TestValidateWorkspaceName(t *testing.T) { } } +// PR #340: Verify validateWorkspaceName returns structured CLIErrors +func TestValidateWorkspaceNameStructuredErrors(t *testing.T) { + tests := []struct { + name string + workspace string + wantContains string + }{ + {"empty", "", "cannot be empty"}, + {"dot", ".", "cannot be '.' or '..'"}, + {"dotdot", "..", "cannot be '.' or '..'"}, + {"starts with dot", ".hidden", "cannot start with '.' or '-'"}, + {"starts with dash", "-bad", "cannot start with '.' or '-'"}, + {"ends with dot", "bad.", "cannot end with '.' or '/'"}, + {"ends with slash", "bad/", "cannot end with '.' or '/'"}, + {"contains dotdot", "bad..name", "cannot contain '..'"}, + {"contains space", "bad name", "cannot contain ' '"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateWorkspaceName(tt.workspace) + if err == nil { + t.Fatalf("expected error for workspace name %q", tt.workspace) + } + + // Verify it's a CLIError (structured error from PR #340) + cliErr, ok := err.(*errors.CLIError) + if !ok { + t.Fatalf("expected *errors.CLIError, got %T: %v", err, err) + } + + if cliErr.Category != errors.CategoryUsage { + t.Errorf("expected CategoryUsage, got %v", cliErr.Category) + } + + if !strings.Contains(cliErr.Message, tt.wantContains) { + t.Errorf("expected message to contain %q, got: %s", tt.wantContains, cliErr.Message) + } + + // All invalid workspace name errors should suggest naming conventions + if cliErr.Suggestion == "" { + t.Error("expected a suggestion for naming conventions") + } + }) + } +} + func TestCLIWorkspaceListEmpty(t *testing.T) { cli, d, cleanup := setupTestEnvironment(t) defer cleanup() @@ -2811,6 +3124,163 @@ func TestVersionCommandJSON(t *testing.T) { } } +func TestHelpJSON(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Test --json flag at root level + err := cli.Execute([]string{"--json"}) + if err != nil { + t.Errorf("Execute(--json) failed: %v", err) + } + + // Test --help --json combination + err = cli.Execute([]string{"--help", "--json"}) + if err != nil { + t.Errorf("Execute(--help --json) failed: %v", err) + } + + // Test subcommand --json + err = cli.Execute([]string{"agent", "--json"}) + if err != nil { + t.Errorf("Execute(agent --json) failed: %v", err) + } +} + +func TestCommandSchemaConversion(t *testing.T) { + cmd := &Command{ + Name: "test", + Description: "test command", + Usage: "multiclaude test [args]", + Subcommands: map[string]*Command{ + "sub": { + Name: "sub", + Description: "subcommand", + Usage: "multiclaude test sub", + }, + "_internal": { + Name: "_internal", + Description: "internal command", + }, + }, + } + + schema := cmd.toSchema() + + if schema.Name != "test" { + t.Errorf("expected name 'test', got '%s'", schema.Name) + } + if schema.Description != "test command" { + t.Errorf("expected description 'test command', got '%s'", schema.Description) + } + if schema.Usage != "multiclaude test [args]" { + t.Errorf("expected usage 'multiclaude test [args]', got '%s'", schema.Usage) + } + if len(schema.Subcommands) != 1 { + t.Errorf("expected 1 subcommand (internal should be filtered), got %d", len(schema.Subcommands)) + } + if _, exists := schema.Subcommands["sub"]; !exists { + t.Error("expected 'sub' subcommand to exist") + } + if _, exists := schema.Subcommands["_internal"]; exists { + t.Error("internal commands should be filtered from schema") + } +} + +// PR #335: Additional JSON output edge cases + +func TestCommandSchemaEmptySubcommands(t *testing.T) { + cmd := &Command{ + Name: "leaf", + Description: "leaf command with no subcommands", + } + + schema := cmd.toSchema() + + if schema.Name != "leaf" { + t.Errorf("expected name 'leaf', got '%s'", schema.Name) + } + if schema.Subcommands != nil { + t.Errorf("expected nil subcommands for leaf command, got %v", schema.Subcommands) + } +} + +func TestCommandSchemaNestedSubcommands(t *testing.T) { + cmd := &Command{ + Name: "root", + Description: "root command", + Subcommands: map[string]*Command{ + "level1": { + Name: "level1", + Description: "level 1", + Subcommands: map[string]*Command{ + "level2": { + Name: "level2", + Description: "level 2", + Usage: "root level1 level2", + }, + }, + }, + }, + } + + schema := cmd.toSchema() + + l1, exists := schema.Subcommands["level1"] + if !exists { + t.Fatal("expected level1 subcommand") + } + l2, exists := l1.Subcommands["level2"] + if !exists { + t.Fatal("expected level2 nested subcommand") + } + if l2.Usage != "root level1 level2" { + t.Errorf("expected nested usage, got: %s", l2.Usage) + } +} + +func TestCommandSchemaAllInternalFiltered(t *testing.T) { + cmd := &Command{ + Name: "test", + Description: "test", + Subcommands: map[string]*Command{ + "_a": {Name: "_a", Description: "internal a"}, + "_b": {Name: "_b", Description: "internal b"}, + }, + } + + schema := cmd.toSchema() + + // When all subcommands are internal, map should be empty but not nil + if len(schema.Subcommands) != 0 { + t.Errorf("expected 0 subcommands (all internal filtered), got %d", len(schema.Subcommands)) + } +} + +func TestHelpJSONSubcommandOutput(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Test various subcommand --json combinations + subcommands := [][]string{ + {"repo", "--json"}, + {"worker", "--json"}, + {"workspace", "--json"}, + {"daemon", "--json"}, + {"message", "--json"}, + {"agent", "--help", "--json"}, + } + + for _, args := range subcommands { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + err := cli.Execute(args) + if err != nil { + t.Errorf("Execute(%v) failed: %v", args, err) + } + }) + } +} + func TestShowHelpNoPanic(t *testing.T) { cli, _, cleanup := setupTestEnvironment(t) defer cleanup() @@ -2822,7 +3292,7 @@ func TestShowHelpNoPanic(t *testing.T) { } }() - cli.showHelp() + cli.showHelp(false) } func TestExecuteEmptyArgs(t *testing.T) { @@ -3637,3 +4107,377 @@ func TestLoadStateFunction(t *testing.T) { } }) } + +// ============================================================================= +// Tests for PR #338: Token-aware status display and help improvements +// ============================================================================= + +func TestHibernateCommandIfExists(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + repoCmd, ok := cli.rootCmd.Subcommands["repo"] + if !ok { + t.Fatal("Expected 'repo' command to exist") + } + + hibernateCmd, ok := repoCmd.Subcommands["hibernate"] + if !ok { + t.Skip("hibernate subcommand not yet present") + } + + if hibernateCmd.Description == "" { + t.Error("hibernate command should have a description") + } + if hibernateCmd.Usage == "" { + t.Error("hibernate command should have usage text") + } +} + +func TestHibernateHelpRendering(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + repoCmd, ok := cli.rootCmd.Subcommands["repo"] + if !ok { + t.Fatal("Expected 'repo' command to exist") + } + + if _, ok := repoCmd.Subcommands["hibernate"]; !ok { + t.Skip("hibernate subcommand not yet present") + } + + err := cli.Execute([]string{"repo", "hibernate", "--help"}) + if err != nil { + t.Errorf("repo hibernate --help should not error: %v", err) + } +} + +func TestShowCommandHelpBasic(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + cmd := &Command{ + Name: "test-help", + Description: "Test help rendering", + Usage: "test-help [options]", + } + + err := cli.showCommandHelp(cmd, false) + if err != nil { + t.Errorf("showCommandHelp() returned error: %v", err) + } +} + +func TestHandleListReposRichResponseShape(t *testing.T) { + _, d, cleanup := setupTestEnvironment(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-repo", + Agents: make(map[string]state.Agent), + } + if err := d.GetState().AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + d.GetState().AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + }) + d.GetState().AddAgent("test-repo", "merge-queue", state.Agent{ + Type: state.AgentTypeMergeQueue, + TmuxWindow: "merge-queue", + }) + d.GetState().AddAgent("test-repo", "happy-fox", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "happy-fox", + Task: "implement feature X", + }) + d.GetState().AddAgent("test-repo", "clever-bear", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "clever-bear", + Task: "fix bug Y", + }) + + client := socket.NewClient(d.GetPaths().DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + if !resp.Success { + t.Fatalf("list_repos failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]interface{}) + if !ok { + t.Fatalf("Expected repos array, got %T", resp.Data) + } + if len(repos) != 1 { + t.Fatalf("Expected 1 repo, got %d", len(repos)) + } + + repoMap, ok := repos[0].(map[string]interface{}) + if !ok { + t.Fatalf("Expected repo map, got %T", repos[0]) + } + + totalAgents, ok := repoMap["total_agents"].(float64) + if !ok { + t.Fatal("Expected total_agents field") + } + if int(totalAgents) != 4 { + t.Errorf("total_agents = %v, want 4", totalAgents) + } + + workerCount, ok := repoMap["worker_count"].(float64) + if !ok { + t.Fatal("Expected worker_count field") + } + if int(workerCount) != 2 { + t.Errorf("worker_count = %v, want 2", workerCount) + } +} + +func TestHandleListReposRichEmptyAgents(t *testing.T) { + _, d, cleanup := setupTestEnvironment(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-repo", + Agents: make(map[string]state.Agent), + } + if err := d.GetState().AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + client := socket.NewClient(d.GetPaths().DaemonSock) + resp, err := client.Send(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + if !resp.Success { + t.Fatalf("list_repos failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]interface{}) + if !ok { + t.Fatalf("Expected repos array, got %T", resp.Data) + } + if len(repos) != 1 { + t.Fatalf("Expected 1 repo, got %d", len(repos)) + } + + repoMap := repos[0].(map[string]interface{}) + totalAgents := repoMap["total_agents"].(float64) + if int(totalAgents) != 0 { + t.Errorf("total_agents = %v, want 0", totalAgents) + } + workerCount := repoMap["worker_count"].(float64) + if int(workerCount) != 0 { + t.Errorf("worker_count = %v, want 0", workerCount) + } +} + +// ============================================================================= +// Tests for PR #339: Context-aware refresh with auto-detection +// ============================================================================= + +func TestRefreshContextDetectionFromWorktreePath(t *testing.T) { + tests := []struct { + name string + cwdSuffix string + wantRepo string + wantAgent string + wantDetect bool + }{ + { + name: "agent worktree path", + cwdSuffix: "my-repo/happy-fox", + wantRepo: "my-repo", + wantAgent: "happy-fox", + wantDetect: true, + }, + { + name: "agent worktree with deep subdirectory", + cwdSuffix: "my-repo/happy-fox/src/main", + wantRepo: "my-repo", + wantAgent: "happy-fox", + wantDetect: true, + }, + { + name: "repo-only path", + cwdSuffix: "my-repo", + wantRepo: "my-repo", + wantAgent: "", + wantDetect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "refresh-ctx-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + wtsDir := filepath.Join(tmpDir, "wts") + + testPath := filepath.Join(wtsDir, tt.cwdSuffix) + if err := os.MkdirAll(testPath, 0755); err != nil { + t.Fatalf("Failed to create test path: %v", err) + } + + if !hasPathPrefix(testPath, wtsDir) { + t.Fatal("testPath should have wtsDir as prefix") + } + + rel, err := filepath.Rel(wtsDir, testPath) + if err != nil { + t.Fatalf("filepath.Rel failed: %v", err) + } + + parts := strings.SplitN(rel, string(filepath.Separator), 2) + detected := len(parts) >= 2 && parts[0] != "" && parts[1] != "" + + if detected != tt.wantDetect { + t.Errorf("detection = %v, want %v (parts=%v)", detected, tt.wantDetect, parts) + } + + if detected { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + if repoName != tt.wantRepo { + t.Errorf("repoName = %q, want %q", repoName, tt.wantRepo) + } + if agentName != tt.wantAgent { + t.Errorf("agentName = %q, want %q", agentName, tt.wantAgent) + } + } + }) + } +} + +func TestRefreshParseFlagsAllFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantAll bool + }{ + {"no flags", []string{}, false}, + {"all flag present", []string{"--all"}, true}, + {"other flags without all", []string{"--repo", "my-repo"}, false}, + {"all flag with other flags", []string{"--all", "--repo", "my-repo"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flags, _ := ParseFlags(tt.args) + gotAll := flags["all"] == "true" + if gotAll != tt.wantAll { + t.Errorf("--all = %v, want %v (flags=%v)", gotAll, tt.wantAll, flags) + } + }) + } +} + +func TestRefreshUsageUpdated(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + refreshCmd, ok := cli.rootCmd.Subcommands["refresh"] + if !ok { + t.Fatal("Expected 'refresh' command to exist") + } + + if refreshCmd.Usage == "" { + t.Error("refresh command should have a Usage string") + } + if refreshCmd.Description == "" { + t.Error("refresh command should have a description") + } +} + +func TestContextDetectionEdgeCases(t *testing.T) { + tests := []struct { + name string + path string + wtsDir string + wantMatch bool + }{ + {"path outside worktrees dir", "/home/user/projects/my-repo", "/home/user/.multiclaude/wts", false}, + {"path is exactly the wts dir", "/home/user/.multiclaude/wts", "/home/user/.multiclaude/wts", true}, + {"path with similar prefix", "/home/user/.multiclaude/wts-backup/repo/agent", "/home/user/.multiclaude/wts", false}, + {"path with trailing separator", "/home/user/.multiclaude/wts/repo/agent", "/home/user/.multiclaude/wts/", true}, + {"path with dotfiles", "/home/user/.multiclaude/wts/.hidden-repo/agent", "/home/user/.multiclaude/wts", true}, + {"path with spaces", "/home/user/.multiclaude/wts/my repo/agent", "/home/user/.multiclaude/wts", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasPathPrefix(tt.path, tt.wtsDir) + if got != tt.wantMatch { + t.Errorf("hasPathPrefix(%q, %q) = %v, want %v", tt.path, tt.wtsDir, got, tt.wantMatch) + } + }) + } +} + +func TestRefreshCommandHelp(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + err := cli.Execute([]string{"refresh", "--help"}) + if err != nil { + t.Errorf("refresh --help should not error: %v", err) + } +} + +func TestAgentContextPathParsing(t *testing.T) { + tests := []struct { + name string + rel string + wantRepo string + wantAgent string + wantOK bool + }{ + {"standard two-component", "multiclaude/happy-fox", "multiclaude", "happy-fox", true}, + {"path with subdir", "multiclaude/happy-fox/internal/cli", "multiclaude", "happy-fox", true}, + {"single component", "multiclaude", "", "", false}, + {"empty relative path", ".", "", "", false}, + {"repo with dots", "my.repo.name/agent-1", "my.repo.name", "agent-1", true}, + {"repo with hyphens", "my-cool-repo/lively-otter", "my-cool-repo", "lively-otter", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parts := strings.SplitN(tt.rel, string(filepath.Separator), 2) + ok := len(parts) >= 2 && parts[0] != "" && parts[1] != "" + + if ok != tt.wantOK { + t.Errorf("detection = %v, want %v (parts=%v)", ok, tt.wantOK, parts) + return + } + + if ok { + repoName := parts[0] + agentName := strings.SplitN(parts[1], string(filepath.Separator), 2)[0] + if repoName != tt.wantRepo { + t.Errorf("repo = %q, want %q", repoName, tt.wantRepo) + } + if agentName != tt.wantAgent { + t.Errorf("agent = %q, want %q", agentName, tt.wantAgent) + } + } + }) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c755412..6a981d3 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -419,6 +419,14 @@ func (d *Daemon) routeMessages() { d.logger.Info("Delivered message %s from %s to %s/%s", msg.ID, msg.From, repoName, agentName) } + + // Clean up acknowledged messages to prevent pile-up + count, err := msgMgr.DeleteAcked(repoName, agentName) + if err != nil { + d.logger.Error("Failed to clean up acked messages for %s/%s: %v", repoName, agentName, err) + } else if count > 0 { + d.logger.Debug("Cleaned up %d acked messages for %s/%s", count, repoName, agentName) + } } } } @@ -611,7 +619,11 @@ func (d *Daemon) TriggerWorktreeRefresh() { d.refreshWorktrees() } -// handleRequest handles incoming socket requests +// handleRequest handles incoming socket requests. +// NOTE: This switch defines the socket API surface. When adding or changing +// commands, update docs/extending/SOCKET_API.md and rerun +// `go run ./cmd/verify-docs` so downstream tooling and OpenAPI consumers stay +// in sync. func (d *Daemon) handleRequest(req socket.Request) socket.Response { d.logger.Debug("Handling request: %s", req.Command) @@ -728,12 +740,17 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { // Return detailed repo info repoDetails := make([]map[string]interface{}, 0, len(repos)) for repoName, repo := range repos { - // Count agents by type - workerCount := 0 - totalAgents := len(repo.Agents) - for _, agent := range repo.Agents { + // Group agents by type + workerNames := []string{} + coreAgents := []map[string]string{} // name -> type for core agents + for agentName, agent := range repo.Agents { if agent.Type == state.AgentTypeWorker { - workerCount++ + workerNames = append(workerNames, agentName) + } else { + coreAgents = append(coreAgents, map[string]string{ + "name": agentName, + "type": string(agent.Type), + }) } } @@ -753,8 +770,10 @@ func (d *Daemon) handleListRepos(req socket.Request) socket.Response { "name": repoName, "github_url": repo.GithubURL, "tmux_session": repo.TmuxSession, - "total_agents": totalAgents, - "worker_count": workerCount, + "total_agents": len(repo.Agents), + "worker_count": len(workerNames), + "worker_names": workerNames, + "core_agents": coreAgents, "session_healthy": sessionHealthy, "is_fork": repo.ForkConfig.IsFork, "upstream_owner": repo.ForkConfig.UpstreamOwner, @@ -1245,14 +1264,223 @@ 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.SuccessResponse(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) + } + + // 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 + } + + // Use shared logic to determine which agents are missing + missing := state.MissingCoreAgents(repo) + created := 0 + + for _, spec := range missing { + d.logger.Info("Creating missing %s agent for %s", spec.Name, repoName) + if err := d.spawnCoreAgent(repoName, spec.Name, spec.Type); err != nil { + return created, fmt.Errorf("failed to create %s: %w", spec.Name, err) + } + created++ + } + + return created, nil +} + +// spawnCoreAgent creates a new core agent from scratch (supervisor, merge-queue, or pr-shepherd). +// Unlike handleRestartAgent which requires the agent to already exist in state, +// this creates the tmux window, writes the prompt, starts Claude, and registers in state. +func (d *Daemon) spawnCoreAgent(repoName, agentName string, agentType state.AgentType) error { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return fmt.Errorf("repository %s not found in state", repoName) + } + + repoPath := d.paths.RepoDir(repoName) + + // Create tmux window (core agents run from repo dir, not a worktree) + hasWindow, _ := d.tmux.HasWindow(d.ctx, repo.TmuxSession, agentName) + if !hasWindow { + cmd := exec.Command("tmux", "new-window", "-d", "-t", repo.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 + promptFile, err := d.writePromptFile(repoName, agentType, agentName) + if err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, repoPath); err != nil { + d.logger.Warn("Failed to copy hooks config for %s: %v", agentName, err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + result, err := d.claudeRunner.Start(d.ctx, repo.TmuxSession, agentName, claude.Config{ + SessionID: sessionID, + Resume: false, + SystemPromptFile: promptFile, + }) + if err != nil { + return fmt.Errorf("failed to start Claude: %w", err) + } + pid = result.PID + } + + // Register agent in state + agent := state.Agent{ + Type: agentType, + WorktreePath: repoPath, + TmuxWindow: agentName, + SessionID: sessionID, + PID: pid, + } + + if err := d.state.AddAgent(repoName, agentName, agent); err != nil { + return fmt.Errorf("failed to add agent to state: %w", err) + } + + d.logger.Info("Created core agent %s (%s) for %s with PID %d", agentName, agentType, repoName, pid) + 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) + } + + if repo.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) + + 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 + 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 using the standard helper (consistent with restartAgent) + promptFile, err := d.writePromptFile(repoName, 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 { + 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" { + result, err := d.claudeRunner.Start(d.ctx, repo.TmuxSession, workspaceName, claude.Config{ + SessionID: sessionID, + Resume: false, + SystemPromptFile: promptFile, + }) + if err != nil { + return false, fmt.Errorf("failed to start Claude: %w", err) + } + pid = result.PID + } + + // 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") @@ -1458,7 +1686,7 @@ func (d *Daemon) recordTaskHistory(repoName, agentName string, agent state.Agent branch = b } else { // Fallback: construct expected branch name - branch = "work/" + agentName + branch = "multiclaude/" + agentName } } @@ -1605,7 +1833,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { worktreePath = repoPath } else { // Ephemeral agents get their own worktree with a new branch - branchName := fmt.Sprintf("work/%s", agentName) + branchName := fmt.Sprintf("multiclaude/%s", agentName) if err := wt.CreateNewBranch(worktreePath, branchName, "HEAD"); err != nil { return socket.ErrorResponse("failed to create worktree: %v", err) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index a882edb..f8c7934 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -1182,6 +1182,213 @@ func TestMessageRoutingWithRealTmux(t *testing.T) { } } +func TestMessageRoutingCleansUpAckedMessages(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Fatal("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Create a real tmux session + sessionName := "mc-test-cleanup" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Fatalf("tmux is required for this test but cannot create sessions in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + // Create window for worker + if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker1"); err != nil { + t.Fatalf("Failed to create worker window: %v", err) + } + + // Add repo and agent + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + worker := state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker1", + Task: "Test task", + CreatedAt: time.Now(), + } + if err := d.state.AddAgent("test-repo", "worker1", worker); err != nil { + t.Fatalf("Failed to add worker: %v", err) + } + + // Create messages and immediately ack them + msgMgr := messages.NewManager(d.paths.MessagesDir) + for i := 0; i < 5; i++ { + msg, err := msgMgr.Send("test-repo", "supervisor", "worker1", "Test message") + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + // Mark as acked + if err := msgMgr.Ack("test-repo", "worker1", msg.ID); err != nil { + t.Fatalf("Failed to ack message: %v", err) + } + } + + // Verify we have 5 acked messages + allMsgs, err := msgMgr.List("test-repo", "worker1") + if err != nil { + t.Fatalf("Failed to list messages: %v", err) + } + if len(allMsgs) != 5 { + t.Fatalf("Expected 5 messages, got %d", len(allMsgs)) + } + + // Trigger message routing which should clean up acked messages + d.TriggerMessageRouting() + + // Verify acked messages were deleted + remainingMsgs, err := msgMgr.List("test-repo", "worker1") + if err != nil { + t.Fatalf("Failed to list messages after cleanup: %v", err) + } + if len(remainingMsgs) != 0 { + t.Errorf("Expected 0 messages after cleanup, got %d", len(remainingMsgs)) + } +} + +// PR #342: Test message cleanup with no acked messages (edge case) +func TestMessageRoutingNoAckedMessages(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Fatal("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-noack" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Fatalf("tmux is required for this test but cannot create sessions in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker1"); err != nil { + t.Fatalf("Failed to create worker window: %v", err) + } + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + worker := state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker1", + Task: "Test task", + CreatedAt: time.Now(), + } + if err := d.state.AddAgent("test-repo", "worker1", worker); err != nil { + t.Fatalf("Failed to add worker: %v", err) + } + + // Send messages but DON'T ack them + msgMgr := messages.NewManager(d.paths.MessagesDir) + for i := 0; i < 3; i++ { + _, err := msgMgr.Send("test-repo", "supervisor", "worker1", "Test message") + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + } + + // Trigger message routing - should not delete unacked messages + d.TriggerMessageRouting() + + // Verify all 3 messages still exist (none were acked) + msgs, err := msgMgr.List("test-repo", "worker1") + if err != nil { + t.Fatalf("Failed to list messages: %v", err) + } + if len(msgs) != 3 { + t.Errorf("Expected 3 unacked messages to remain, got %d", len(msgs)) + } +} + +// PR #342: Test mixed acked and unacked messages +func TestMessageRoutingMixedAckStatus(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Fatal("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-mixed" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Fatalf("tmux is required for this test but cannot create sessions in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker1"); err != nil { + t.Fatalf("Failed to create worker window: %v", err) + } + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + worker := state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker1", + Task: "Test task", + CreatedAt: time.Now(), + } + if err := d.state.AddAgent("test-repo", "worker1", worker); err != nil { + t.Fatalf("Failed to add worker: %v", err) + } + + // Send 4 messages, ack 2 + msgMgr := messages.NewManager(d.paths.MessagesDir) + var msgIDs []string + for i := 0; i < 4; i++ { + msg, err := msgMgr.Send("test-repo", "supervisor", "worker1", "Test message") + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + msgIDs = append(msgIDs, msg.ID) + } + + // Ack first 2 messages + for _, id := range msgIDs[:2] { + if err := msgMgr.Ack("test-repo", "worker1", id); err != nil { + t.Fatalf("Failed to ack message: %v", err) + } + } + + // Trigger routing + d.TriggerMessageRouting() + + // Verify only unacked messages remain + msgs, err := msgMgr.List("test-repo", "worker1") + if err != nil { + t.Fatalf("Failed to list messages: %v", err) + } + if len(msgs) != 2 { + t.Errorf("Expected 2 unacked messages to remain, got %d", len(msgs)) + } +} + func TestWakeLoopUpdatesNudgeTime(t *testing.T) { tmuxClient := tmux.NewClient() if !tmuxClient.IsTmuxAvailable() { @@ -3830,3 +4037,273 @@ func TestRecordTaskHistoryWithSummary(t *testing.T) { t.Errorf("History entry summary = %q, want 'Implemented the feature successfully'", history[0].Summary) } } + +// TestEnsureCoreAgentsEmptyState tests ensureCoreAgents when the repo +// doesn't exist in state, verifying it returns an error. +func TestEnsureCoreAgentsEmptyState(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Call with a repo that doesn't exist in state + created, err := d.ensureCoreAgents("nonexistent-repo") + if err == nil { + t.Fatal("Expected error for nonexistent repo, got nil") + } + if created != 0 { + t.Errorf("Expected 0 agents created, got %d", created) + } + if !strings.Contains(err.Error(), "nonexistent-repo") { + t.Errorf("Error should mention repo name, got: %s", err.Error()) + } +} + +// TestEnsureCoreAgentsNoTmuxSession tests ensureCoreAgents when the repo +// exists but the tmux session doesn't — should skip gracefully. +func TestEnsureCoreAgentsNoTmuxSession(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // Add a repo with a tmux session name that doesn't exist + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-nonexistent-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error when session missing, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (no tmux session), got %d", created) + } +} + +// TestEnsureCoreAgentsSkipsExistingAgents tests that ensureCoreAgents does +// NOT attempt to recreate agents that already exist in state. +func TestEnsureCoreAgentsSkipsExistingAgents(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Skip("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-ensure-core" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Skipf("Cannot create tmux session in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + // Add repo with existing supervisor agent + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + PID: 99999, + CreatedAt: time.Now(), + }, + "merge-queue": { + Type: state.AgentTypeMergeQueue, + TmuxWindow: "merge-queue", + PID: 99998, + CreatedAt: time.Now(), + }, + }, + MergeQueueConfig: state.DefaultMergeQueueConfig(), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (all exist), got %d", created) + } + + // Verify agents are still there unchanged + agent, exists := d.state.GetAgent("test-repo", "supervisor") + if !exists { + t.Error("Supervisor agent should still exist") + } + if agent.PID != 99999 { + t.Errorf("Supervisor PID should be unchanged (99999), got %d", agent.PID) + } + + agent, exists = d.state.GetAgent("test-repo", "merge-queue") + if !exists { + t.Error("Merge-queue agent should still exist") + } + if agent.PID != 99998 { + t.Errorf("Merge-queue PID should be unchanged (99998), got %d", agent.PID) + } +} + +// TestEnsureCoreAgentsSkipsForkModeWithPRShepherdExisting tests that in +// fork mode, ensureCoreAgents skips creating pr-shepherd when it already exists. +func TestEnsureCoreAgentsSkipsForkModeWithPRShepherdExisting(t *testing.T) { + tmuxClient := tmux.NewClient() + if !tmuxClient.IsTmuxAvailable() { + t.Skip("tmux is required for this test but not available") + } + + d, cleanup := setupTestDaemon(t) + defer cleanup() + + sessionName := "mc-test-fork-skip" + if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil { + t.Skipf("Cannot create tmux session in this environment: %v", err) + } + defer tmuxClient.KillSession(context.Background(), sessionName) + + // Fork mode repo with supervisor + pr-shepherd already present + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: sessionName, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + PID: 99999, + CreatedAt: time.Now(), + }, + "pr-shepherd": { + Type: state.AgentTypePRShepherd, + TmuxWindow: "pr-shepherd", + PID: 99997, + CreatedAt: time.Now(), + }, + }, + ForkConfig: state.ForkConfig{IsFork: true}, + PRShepherdConfig: state.DefaultPRShepherdConfig(), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureCoreAgents("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created != 0 { + t.Errorf("Expected 0 agents created (pr-shepherd exists), got %d", created) + } +} + +// TestEnsureDefaultWorkspaceEmptyState tests ensureDefaultWorkspace when +// the repo doesn't exist in state. +func TestEnsureDefaultWorkspaceEmptyState(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + created, err := d.ensureDefaultWorkspace("nonexistent-repo") + if err == nil { + t.Fatal("Expected error for nonexistent repo, got nil") + } + if created { + t.Error("Expected false when repo doesn't exist") + } + if !strings.Contains(err.Error(), "nonexistent-repo") { + t.Errorf("Error should mention repo name, got: %s", err.Error()) + } +} + +// TestEnsureDefaultWorkspaceSkipsExisting tests that ensureDefaultWorkspace +// returns (false, nil) when a workspace agent already exists. +func TestEnsureDefaultWorkspaceSkipsExisting(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-ws", + Agents: map[string]state.Agent{ + "my-workspace": { + Type: state.AgentTypeWorkspace, + TmuxWindow: "my-workspace", + PID: 88888, + CreatedAt: time.Now(), + }, + }, + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureDefaultWorkspace("test-repo") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if created { + t.Error("Expected false when workspace already exists") + } +} + +// TestEnsureDefaultWorkspaceNoTmuxSession tests that ensureDefaultWorkspace +// skips creation gracefully when no tmux session exists. +func TestEnsureDefaultWorkspaceNoTmuxSession(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-nonexistent-session", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + created, err := d.ensureDefaultWorkspace("test-repo") + if err != nil { + t.Errorf("Expected no error when session missing, got: %v", err) + } + if created { + t.Error("Expected false when tmux session doesn't exist") + } +} + +// TestSpawnCoreAgentErrorIncludesDetails tests that spawnCoreAgent wraps +// the handleRestartAgent error message (resp.Error) in the returned error. +func TestSpawnCoreAgentErrorIncludesDetails(t *testing.T) { + d, cleanup := setupTestDaemon(t) + defer cleanup() + + // spawnCoreAgent with a repo not in state should fail + err := d.spawnCoreAgent("nonexistent-repo", "supervisor", state.AgentTypeSupervisor) + if err == nil { + t.Fatal("Expected error from spawnCoreAgent for missing repo, got nil") + } + if !strings.Contains(err.Error(), "not found in state") { + t.Errorf("Error should mention 'not found in state', got: %s", err.Error()) + } + + // spawnCoreAgent with a valid repo but no tmux should fail at window creation + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-spawn-nonexistent", + Agents: make(map[string]state.Agent), + } + if err := d.state.AddRepo("test-repo", repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + err = d.spawnCoreAgent("test-repo", "supervisor", state.AgentTypeSupervisor) + if err == nil { + t.Fatal("Expected error from spawnCoreAgent without tmux, got nil") + } + errMsg := err.Error() + if !strings.Contains(errMsg, "failed to create tmux window") { + t.Errorf("Error should mention tmux window creation failure, got: %s", errMsg) + } +} diff --git a/internal/daemon/handlers_test.go b/internal/daemon/handlers_test.go index 9e66ebb..ae8de52 100644 --- a/internal/daemon/handlers_test.go +++ b/internal/daemon/handlers_test.go @@ -2033,3 +2033,145 @@ func TestHandleGetRepoConfigTableDriven(t *testing.T) { }) } } + +// ============================================================================= +// Tests for PR #338: handleListRepos enriched response +// ============================================================================= + +func TestHandleListReposRichAgentBreakdown(t *testing.T) { + tests := []struct { + name string + setupState func(*state.State) + wantRepoCount int + wantTotalAgents int + wantWorkerCount int + }{ + { + name: "empty repo", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 0, + wantWorkerCount: 0, + }, + { + name: "repo with mixed agents", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + CreatedAt: time.Now(), + }) + s.AddAgent("test-repo", "worker-1", state.Agent{ + Type: state.AgentTypeWorker, + TmuxWindow: "worker-1", + Task: "fix bug", + CreatedAt: time.Now(), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 2, + wantWorkerCount: 1, + }, + { + name: "repo with only core agents", + setupState: func(s *state.State) { + s.AddRepo("test-repo", &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test", + Agents: make(map[string]state.Agent), + }) + s.AddAgent("test-repo", "supervisor", state.Agent{ + Type: state.AgentTypeSupervisor, + TmuxWindow: "supervisor", + CreatedAt: time.Now(), + }) + }, + wantRepoCount: 1, + wantTotalAgents: 1, + wantWorkerCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, tt.setupState) + defer cleanup() + + resp := d.handleListRepos(socket.Request{ + Command: "list_repos", + Args: map[string]interface{}{"rich": true}, + }) + + if !resp.Success { + t.Fatalf("handleListRepos() failed: %s", resp.Error) + } + + repos, ok := resp.Data.([]map[string]interface{}) + if !ok { + t.Fatalf("Expected []map[string]interface{}, got %T", resp.Data) + } + + if len(repos) != tt.wantRepoCount { + t.Errorf("repo count = %d, want %d", len(repos), tt.wantRepoCount) + } + + if len(repos) > 0 { + repo := repos[0] + + totalAgents, _ := repo["total_agents"].(int) + if totalAgents != tt.wantTotalAgents { + t.Errorf("total_agents = %d, want %d", totalAgents, tt.wantTotalAgents) + } + + workerCount, _ := repo["worker_count"].(int) + if workerCount != tt.wantWorkerCount { + t.Errorf("worker_count = %d, want %d", workerCount, tt.wantWorkerCount) + } + } + }) + } +} + +func TestHandleListReposSimpleFormat(t *testing.T) { + d, cleanup := setupTestDaemonWithState(t, func(s *state.State) { + s.AddRepo("repo-a", &state.Repository{ + GithubURL: "https://github.com/test/repo-a", + TmuxSession: "mc-repo-a", + Agents: make(map[string]state.Agent), + }) + s.AddRepo("repo-b", &state.Repository{ + GithubURL: "https://github.com/test/repo-b", + TmuxSession: "mc-repo-b", + Agents: make(map[string]state.Agent), + }) + }) + defer cleanup() + + resp := d.handleListRepos(socket.Request{ + Command: "list_repos", + }) + + if !resp.Success { + t.Fatalf("handleListRepos() failed: %s", resp.Error) + } + + names, ok := resp.Data.([]string) + if !ok { + t.Fatalf("Expected []string for simple format, got %T", resp.Data) + } + + if len(names) != 2 { + t.Errorf("Expected 2 repo names, got %d", len(names)) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6798ebd..0ec601f 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -391,3 +391,173 @@ func WorkspaceNotFound(name, repo string) *CLIError { Suggestion: fmt.Sprintf("multiclaude workspace list --repo %s", repo), } } + +// RepoAlreadyExists creates an error for when trying to init an already tracked repo +func RepoAlreadyExists(name string) *CLIError { + return &CLIError{ + Category: CategoryConfig, + Message: fmt.Sprintf("repository '%s' is already initialized", name), + Suggestion: fmt.Sprintf("multiclaude repo rm %s # to remove and re-init", name), + } +} + +// DirectoryAlreadyExists creates an error for when a directory already exists +func DirectoryAlreadyExists(path string) *CLIError { + return &CLIError{ + Category: CategoryConfig, + Message: fmt.Sprintf("directory already exists: %s", path), + Suggestion: "remove the directory manually or choose a different name", + } +} + +// WorkspaceAlreadyExists creates an error for when a workspace already exists +func WorkspaceAlreadyExists(name, repo string) *CLIError { + return &CLIError{ + Category: CategoryConfig, + Message: fmt.Sprintf("workspace '%s' already exists in repo '%s'", name, repo), + Suggestion: fmt.Sprintf("multiclaude workspace list --repo %s", repo), + } +} + +// InvalidWorkspaceName creates an error for invalid workspace names +func InvalidWorkspaceName(name, reason string) *CLIError { + return &CLIError{ + Category: CategoryUsage, + Message: fmt.Sprintf("invalid workspace name '%s': %s", name, reason), + Suggestion: "workspace names should be alphanumeric with hyphens or underscores (e.g., 'my-workspace')", + } +} + +// LogFileNotFound creates an error for when no log file exists for an agent +func LogFileNotFound(agent, repo string) *CLIError { + return &CLIError{ + Category: CategoryNotFound, + Message: fmt.Sprintf("no log file found for agent '%s' in repo '%s'", agent, repo), + Suggestion: "the agent may not have been started yet or logs may have been cleaned up", + } +} + +// InvalidDuration creates an error for invalid duration format +func InvalidDuration(value string) *CLIError { + return &CLIError{ + Category: CategoryUsage, + Message: fmt.Sprintf("invalid duration: %s", value), + Suggestion: "use format like '7d' (days), '24h' (hours), or '30m' (minutes)", + } +} + +// NoDefaultRepo creates an error for when no default repo is set and multiple exist +func NoDefaultRepo() *CLIError { + return &CLIError{ + Category: CategoryUsage, + Message: "could not determine which repository to use", + Suggestion: "use --repo flag, run 'multiclaude repo use ' to set a default, or run from within a tracked repository", + } +} + +// StateLoadFailed creates an error for when state cannot be loaded +func StateLoadFailed(cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: "failed to load multiclaude state", + Cause: cause, + Suggestion: "try 'multiclaude repair' to fix corrupted state", + } +} + +// SessionIDGenerationFailed creates an error for UUID generation failures +func SessionIDGenerationFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to generate session ID for %s", agentType), + Cause: cause, + Suggestion: "this is usually a transient error; try again", + } +} + +// PromptWriteFailed creates an error for prompt file write failures +func PromptWriteFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to write %s prompt file", agentType), + Cause: cause, + Suggestion: "check disk space and permissions in ~/.multiclaude/", + } +} + +// ClaudeStartFailed creates an error for Claude startup failures +func ClaudeStartFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to start %s Claude instance", agentType), + Cause: cause, + Suggestion: "check 'claude --version' works and tmux is running", + } +} + +// AgentRegistrationFailed creates an error for agent registration failures +func AgentRegistrationFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to register %s with daemon", agentType), + Cause: cause, + Suggestion: "multiclaude daemon status", + } +} + +// WorktreeCleanupNeeded creates an error when manual worktree cleanup is needed +func WorktreeCleanupNeeded(path string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: "failed to clean up existing worktree", + Cause: cause, + Suggestion: fmt.Sprintf("git worktree remove %s", path), + } +} + +// TmuxWindowCleanupNeeded creates an error when manual tmux cleanup is needed +func TmuxWindowCleanupNeeded(session, window string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: "failed to clean up existing tmux window", + Cause: cause, + Suggestion: fmt.Sprintf("tmux kill-window -t %s:%s", session, window), + } +} + +// TmuxSessionCleanupNeeded creates an error when manual tmux session cleanup is needed +func TmuxSessionCleanupNeeded(session string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: "failed to clean up existing tmux session", + Cause: cause, + Suggestion: fmt.Sprintf("tmux kill-session -t %s", session), + } +} + +// InvalidTmuxSessionName creates an error for invalid tmux session names +func InvalidTmuxSessionName(reason string) *CLIError { + return &CLIError{ + Category: CategoryUsage, + Message: fmt.Sprintf("invalid tmux session name: %s", reason), + Suggestion: "repository name must not be empty and must be valid for tmux", + } +} + +// WorkerNotFound creates an error for when a worker is not found +func WorkerNotFound(name, repo string) *CLIError { + return &CLIError{ + Category: CategoryNotFound, + Message: fmt.Sprintf("worker '%s' not found in repo '%s'", name, repo), + Suggestion: fmt.Sprintf("multiclaude worker list --repo %s", repo), + } +} + +// AgentNoSessionID creates an error for agents without session IDs +func AgentNoSessionID(name string) *CLIError { + return &CLIError{ + Category: CategoryConfig, + Message: fmt.Sprintf("agent '%s' has no session ID", name), + Suggestion: "try removing and recreating the agent", + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 36a41d4..f5b1fb0 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -294,8 +294,8 @@ func TestWorktreeCreationFailed_SpecificSuggestions(t *testing.T) { }{ { name: "branch already exists with name", - causeMsg: "failed to create worktree: exit status 128\nOutput: fatal: a branch named 'work/nice-owl' already exists", - wantContains: []string{"work/nice-owl", "multiclaude cleanup", "git branch -D work/nice-owl"}, + causeMsg: "failed to create worktree: exit status 128\nOutput: fatal: a branch named 'multiclaude/nice-owl' already exists", + wantContains: []string{"multiclaude/nice-owl", "multiclaude cleanup", "git branch -D multiclaude/nice-owl"}, }, { name: "generic branch already exists", @@ -363,7 +363,7 @@ func TestExtractQuotedValue(t *testing.T) { input string want string }{ - {"fatal: a branch named 'work/nice-owl' already exists", "work/nice-owl"}, + {"fatal: a branch named 'multiclaude/nice-owl' already exists", "multiclaude/nice-owl"}, {"some error 'value' here", "value"}, {"no quotes here", ""}, {"'only-one-quote", ""}, @@ -577,3 +577,389 @@ func TestWorkspaceNotFound(t *testing.T) { t.Errorf("expected workspace list suggestion, got: %s", formatted) } } + +// Tests for PR #340 structured error constructors + +func TestRepoAlreadyExists(t *testing.T) { + err := RepoAlreadyExists("my-repo") + + if err.Category != CategoryConfig { + t.Errorf("expected CategoryConfig, got %v", err.Category) + } + if !strings.Contains(err.Message, "my-repo") { + t.Errorf("expected repo name in message, got: %s", err.Message) + } + if !strings.Contains(err.Message, "already initialized") { + t.Errorf("expected 'already initialized' in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "multiclaude repo rm") { + t.Errorf("expected rm suggestion, got: %s", err.Suggestion) + } + + formatted := Format(err) + if !strings.Contains(formatted, "Configuration error:") { + t.Errorf("expected config error prefix, got: %s", formatted) + } +} + +func TestDirectoryAlreadyExists(t *testing.T) { + err := DirectoryAlreadyExists("/tmp/test-dir") + + if err.Category != CategoryConfig { + t.Errorf("expected CategoryConfig, got %v", err.Category) + } + if !strings.Contains(err.Message, "/tmp/test-dir") { + t.Errorf("expected path in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "remove") { + t.Errorf("expected remove suggestion, got: %s", err.Suggestion) + } +} + +func TestWorkspaceAlreadyExists(t *testing.T) { + err := WorkspaceAlreadyExists("dev", "my-repo") + + if err.Category != CategoryConfig { + t.Errorf("expected CategoryConfig, got %v", err.Category) + } + if !strings.Contains(err.Message, "dev") { + t.Errorf("expected workspace name in message, got: %s", err.Message) + } + if !strings.Contains(err.Message, "my-repo") { + t.Errorf("expected repo name in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "multiclaude workspace list") { + t.Errorf("expected list suggestion, got: %s", err.Suggestion) + } +} + +func TestInvalidWorkspaceName(t *testing.T) { + tests := []struct { + name string + reason string + }{ + {"", "cannot be empty"}, + {".", "cannot be '.' or '..'"}, + {".hidden", "cannot start with '.' or '-'"}, + {"bad..name", "cannot contain '..'"}, + } + + for _, tt := range tests { + t.Run(tt.name+"_"+tt.reason, func(t *testing.T) { + err := InvalidWorkspaceName(tt.name, tt.reason) + + if err.Category != CategoryUsage { + t.Errorf("expected CategoryUsage, got %v", err.Category) + } + if !strings.Contains(err.Message, tt.reason) { + t.Errorf("expected reason in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "alphanumeric") { + t.Errorf("expected naming guidance in suggestion, got: %s", err.Suggestion) + } + }) + } +} + +func TestLogFileNotFound(t *testing.T) { + err := LogFileNotFound("worker1", "my-repo") + + if err.Category != CategoryNotFound { + t.Errorf("expected CategoryNotFound, got %v", err.Category) + } + if !strings.Contains(err.Message, "worker1") { + t.Errorf("expected agent name in message, got: %s", err.Message) + } + if !strings.Contains(err.Message, "my-repo") { + t.Errorf("expected repo name in message, got: %s", err.Message) + } + if err.Suggestion == "" { + t.Error("should have a suggestion") + } +} + +func TestInvalidDuration(t *testing.T) { + err := InvalidDuration("abc") + + if err.Category != CategoryUsage { + t.Errorf("expected CategoryUsage, got %v", err.Category) + } + if !strings.Contains(err.Message, "abc") { + t.Errorf("expected value in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "7d") { + t.Errorf("expected example format in suggestion, got: %s", err.Suggestion) + } +} + +func TestNoDefaultRepo(t *testing.T) { + err := NoDefaultRepo() + + if err.Category != CategoryUsage { + t.Errorf("expected CategoryUsage, got %v", err.Category) + } + if !strings.Contains(err.Message, "could not determine") { + t.Errorf("expected message about repo determination, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "--repo") { + t.Errorf("expected --repo flag in suggestion, got: %s", err.Suggestion) + } + if !strings.Contains(err.Suggestion, "multiclaude repo use") { + t.Errorf("expected repo use suggestion, got: %s", err.Suggestion) + } +} + +func TestStateLoadFailed(t *testing.T) { + cause := errors.New("corrupted json") + err := StateLoadFailed(cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Suggestion, "multiclaude repair") { + t.Errorf("expected repair suggestion, got: %s", err.Suggestion) + } + + formatted := Format(err) + if !strings.Contains(formatted, "corrupted json") { + t.Errorf("expected cause in formatted output, got: %s", formatted) + } +} + +func TestSessionIDGenerationFailed(t *testing.T) { + cause := errors.New("entropy exhausted") + err := SessionIDGenerationFailed("supervisor", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Message, "supervisor") { + t.Errorf("expected agent type in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "try again") { + t.Errorf("expected retry suggestion, got: %s", err.Suggestion) + } +} + +func TestPromptWriteFailed(t *testing.T) { + cause := errors.New("disk full") + err := PromptWriteFailed("worker", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Message, "worker") { + t.Errorf("expected agent type in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "disk space") { + t.Errorf("expected disk space suggestion, got: %s", err.Suggestion) + } +} + +func TestClaudeStartFailed(t *testing.T) { + cause := errors.New("exit code 1") + err := ClaudeStartFailed("merge-queue", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Message, "merge-queue") { + t.Errorf("expected agent type in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "claude --version") { + t.Errorf("expected version check suggestion, got: %s", err.Suggestion) + } +} + +func TestAgentRegistrationFailed(t *testing.T) { + cause := errors.New("socket error") + err := AgentRegistrationFailed("supervisor", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Message, "supervisor") { + t.Errorf("expected agent type in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "multiclaude daemon status") { + t.Errorf("expected daemon status suggestion, got: %s", err.Suggestion) + } +} + +func TestWorktreeCleanupNeeded(t *testing.T) { + cause := errors.New("permission denied") + err := WorktreeCleanupNeeded("/tmp/wt/worker1", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Suggestion, "git worktree remove") { + t.Errorf("expected worktree remove suggestion, got: %s", err.Suggestion) + } + if !strings.Contains(err.Suggestion, "/tmp/wt/worker1") { + t.Errorf("expected path in suggestion, got: %s", err.Suggestion) + } +} + +func TestTmuxWindowCleanupNeeded(t *testing.T) { + cause := errors.New("session not found") + err := TmuxWindowCleanupNeeded("mc-repo", "worker1", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Suggestion, "tmux kill-window") { + t.Errorf("expected kill-window suggestion, got: %s", err.Suggestion) + } + if !strings.Contains(err.Suggestion, "mc-repo:worker1") { + t.Errorf("expected session:window in suggestion, got: %s", err.Suggestion) + } +} + +func TestTmuxSessionCleanupNeeded(t *testing.T) { + cause := errors.New("busy") + err := TmuxSessionCleanupNeeded("mc-repo", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if !strings.Contains(err.Suggestion, "tmux kill-session") { + t.Errorf("expected kill-session suggestion, got: %s", err.Suggestion) + } + if !strings.Contains(err.Suggestion, "mc-repo") { + t.Errorf("expected session name in suggestion, got: %s", err.Suggestion) + } +} + +func TestInvalidTmuxSessionName(t *testing.T) { + err := InvalidTmuxSessionName("repository name cannot be empty") + + if err.Category != CategoryUsage { + t.Errorf("expected CategoryUsage, got %v", err.Category) + } + if !strings.Contains(err.Message, "repository name cannot be empty") { + t.Errorf("expected reason in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "must not be empty") { + t.Errorf("expected naming guidance in suggestion, got: %s", err.Suggestion) + } +} + +func TestWorkerNotFound(t *testing.T) { + err := WorkerNotFound("test-worker", "my-repo") + + if err.Category != CategoryNotFound { + t.Errorf("expected CategoryNotFound, got %v", err.Category) + } + if !strings.Contains(err.Message, "test-worker") { + t.Errorf("expected worker name in message, got: %s", err.Message) + } + if !strings.Contains(err.Message, "my-repo") { + t.Errorf("expected repo name in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "multiclaude worker list") { + t.Errorf("expected list suggestion, got: %s", err.Suggestion) + } +} + +func TestAgentNoSessionID(t *testing.T) { + err := AgentNoSessionID("supervisor") + + if err.Category != CategoryConfig { + t.Errorf("expected CategoryConfig, got %v", err.Category) + } + if !strings.Contains(err.Message, "supervisor") { + t.Errorf("expected agent name in message, got: %s", err.Message) + } + if !strings.Contains(err.Message, "no session ID") { + t.Errorf("expected 'no session ID' in message, got: %s", err.Message) + } + if !strings.Contains(err.Suggestion, "removing and recreating") { + t.Errorf("expected recreate suggestion, got: %s", err.Suggestion) + } +} + +// TestAllNewConstructorsFormat verifies all PR #340 constructors produce valid formatted output +func TestAllNewConstructorsFormat(t *testing.T) { + cause := errors.New("test cause") + + constructors := []struct { + name string + err *CLIError + }{ + {"RepoAlreadyExists", RepoAlreadyExists("repo")}, + {"DirectoryAlreadyExists", DirectoryAlreadyExists("/tmp/dir")}, + {"WorkspaceAlreadyExists", WorkspaceAlreadyExists("ws", "repo")}, + {"InvalidWorkspaceName", InvalidWorkspaceName("bad", "reason")}, + {"LogFileNotFound", LogFileNotFound("agent", "repo")}, + {"InvalidDuration", InvalidDuration("xyz")}, + {"NoDefaultRepo", NoDefaultRepo()}, + {"StateLoadFailed", StateLoadFailed(cause)}, + {"SessionIDGenerationFailed", SessionIDGenerationFailed("worker", cause)}, + {"PromptWriteFailed", PromptWriteFailed("worker", cause)}, + {"ClaudeStartFailed", ClaudeStartFailed("worker", cause)}, + {"AgentRegistrationFailed", AgentRegistrationFailed("worker", cause)}, + {"WorktreeCleanupNeeded", WorktreeCleanupNeeded("/path", cause)}, + {"TmuxWindowCleanupNeeded", TmuxWindowCleanupNeeded("session", "window", cause)}, + {"TmuxSessionCleanupNeeded", TmuxSessionCleanupNeeded("session", cause)}, + {"InvalidTmuxSessionName", InvalidTmuxSessionName("reason")}, + {"WorkerNotFound", WorkerNotFound("name", "repo")}, + {"AgentNoSessionID", AgentNoSessionID("name")}, + } + + for _, tt := range constructors { + t.Run(tt.name, func(t *testing.T) { + // Verify it's a valid CLIError + if tt.err == nil { + t.Fatal("constructor returned nil") + } + + // Verify Error() returns non-empty + if tt.err.Error() == "" { + t.Error("Error() should return non-empty string") + } + + // Verify Format() produces output + formatted := Format(tt.err) + if formatted == "" { + t.Error("Format() should return non-empty string") + } + + // Verify formatted output contains the message + if !strings.Contains(formatted, tt.err.Message) { + t.Errorf("formatted output should contain message %q, got: %s", tt.err.Message, formatted) + } + + // Verify suggestion is included when present + if tt.err.Suggestion != "" { + if !strings.Contains(formatted, "Try:") { + t.Errorf("formatted output should contain 'Try:' for errors with suggestions, got: %s", formatted) + } + } + }) + } +} diff --git a/internal/prompts/commands/refresh.md b/internal/prompts/commands/refresh.md index 8658583..2083cc9 100644 --- a/internal/prompts/commands/refresh.md +++ b/internal/prompts/commands/refresh.md @@ -2,7 +2,22 @@ Sync your worktree with the latest changes from the main branch. -## Instructions +## Quick Method (Recommended) + +Run this CLI command - it handles everything automatically: + +```bash +multiclaude refresh +``` + +This will: +- Detect your worktree context automatically +- Fetch from the correct remote (upstream if fork, otherwise origin) +- Stash any uncommitted changes +- Rebase your branch onto main +- Restore stashed changes + +## Manual Instructions (Alternative) 1. First, determine the correct remote to use. Check if an upstream remote exists (indicates a fork): ```bash diff --git a/internal/state/state.go b/internal/state/state.go index 960a1a4..a8db83d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -156,6 +156,10 @@ type Agent struct { } // Repository represents a tracked repository's state +// NOTE: This schema is an extension surface. When adding or changing fields, +// update docs/extending/STATE_FILE_INTEGRATION.md and rerun +// `go run ./cmd/verify-docs` so downstream readers/LLMs and OpenAPI consumers +// stay in sync. type Repository struct { GithubURL string `json:"github_url"` TmuxSession string `json:"tmux_session"` @@ -167,6 +171,64 @@ type Repository struct { TargetBranch string `json:"target_branch,omitempty"` // Default branch for PRs (usually "main") } +// CoreAgentSpec describes a core agent that should exist for a repository. +type CoreAgentSpec struct { + Name string + Type AgentType +} + +// MissingCoreAgents returns the list of core agents that should exist for a +// repository but are currently missing. This centralizes the decision logic +// for which core agents a repo needs (supervisor always, merge-queue or +// pr-shepherd depending on fork mode and config). +func MissingCoreAgents(repo *Repository) []CoreAgentSpec { + var missing []CoreAgentSpec + + // Supervisor is always required + if _, exists := repo.Agents["supervisor"]; !exists { + missing = append(missing, CoreAgentSpec{Name: "supervisor", Type: AgentTypeSupervisor}) + } + + // Determine fork mode + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + + if isFork { + // Fork mode: pr-shepherd if enabled + psConfig := repo.PRShepherdConfig + if psConfig.TrackMode == "" { + psConfig = DefaultPRShepherdConfig() + } + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + missing = append(missing, CoreAgentSpec{Name: "pr-shepherd", Type: AgentTypePRShepherd}) + } + } + } else { + // Non-fork mode: merge-queue if enabled + mqConfig := repo.MergeQueueConfig + if mqConfig.TrackMode == "" { + mqConfig = DefaultMergeQueueConfig() + } + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + missing = append(missing, CoreAgentSpec{Name: "merge-queue", Type: AgentTypeMergeQueue}) + } + } + } + + return missing +} + +// HasWorkspace returns true if the repository has at least one workspace agent. +func (r *Repository) HasWorkspace() bool { + for _, agent := range r.Agents { + if agent.Type == AgentTypeWorkspace { + return true + } + } + return false +} + // State represents the entire daemon state type State struct { Repos map[string]*Repository `json:"repos"` diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 9b68c10..8b203fa 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1109,7 +1109,7 @@ func TestTaskHistory(t *testing.T) { entry1 := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", Status: TaskStatusUnknown, CreatedAt: time.Now().Add(-2 * time.Hour), CompletedAt: time.Now().Add(-1 * time.Hour), @@ -1117,7 +1117,7 @@ func TestTaskHistory(t *testing.T) { entry2 := TaskHistoryEntry{ Name: "worker-2", Task: "Fix bug B", - Branch: "work/worker-2", + Branch: "multiclaude/worker-2", PRURL: "https://github.com/test/repo/pull/123", PRNumber: 123, Status: TaskStatusMerged, @@ -1226,7 +1226,7 @@ func TestUpdateTaskHistoryStatus(t *testing.T) { entry := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", Status: TaskStatusUnknown, CreatedAt: time.Now().Add(-1 * time.Hour), CompletedAt: time.Now(), @@ -1281,7 +1281,7 @@ func TestTaskHistoryPersistence(t *testing.T) { entry := TaskHistoryEntry{ Name: "worker-1", Task: "Implement feature A", - Branch: "work/worker-1", + Branch: "multiclaude/worker-1", PRURL: "https://github.com/test/repo/pull/789", PRNumber: 789, Status: TaskStatusMerged, diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 7c3a7c3..0fa3e10 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -425,7 +425,7 @@ func (m *Manager) FetchRemote(remote string) error { // FindMergedUpstreamBranches finds local branches that have been merged into the upstream default branch. // It fetches from the upstream remote first to ensure we have the latest state. -// The branchPrefix filters which branches to check (e.g., "multiclaude/" or "work/"). +// The branchPrefix filters which branches to check (e.g., "multiclaude/" or "workspace/"). // Returns a list of branch names that can be safely deleted. func (m *Manager) FindMergedUpstreamBranches(branchPrefix string) ([]string, error) { // Get the upstream remote name diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 8f0b392..e1ac768 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -1194,7 +1194,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch that is already merged (same as main) - createBranch(t, repoPath, "work/test-feature") + createBranch(t, repoPath, "multiclaude/test-feature") // Add origin remote cmd := exec.Command("git", "remote", "add", "origin", repoPath) @@ -1206,7 +1206,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Run() // Find merged branches - merged, err := manager.FindMergedUpstreamBranches("work/") + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1214,7 +1214,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { // The branch should be found since it's at the same commit as main found := false for _, b := range merged { - if b == "work/test-feature" { + if b == "multiclaude/test-feature" { found = true break } @@ -1231,7 +1231,7 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and add a commit to it - cmd := exec.Command("git", "checkout", "-b", "work/unmerged-feature") + cmd := exec.Command("git", "checkout", "-b", "multiclaude/unmerged-feature") cmd.Dir = repoPath cmd.Run() @@ -1262,14 +1262,14 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Run() // Find merged branches - merged, err := manager.FindMergedUpstreamBranches("work/") + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } // The unmerged branch should NOT be found for _, b := range merged { - if b == "work/unmerged-feature" { + if b == "multiclaude/unmerged-feature" { t.Error("Unmerged branch should not be in the merged list") } } @@ -1282,8 +1282,8 @@ func TestFindMergedUpstreamBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/test") createBranch(t, repoPath, "multiclaude/test") + createBranch(t, repoPath, "workspace/test") createBranch(t, repoPath, "feature/test") // Add origin remote @@ -1295,15 +1295,15 @@ func TestFindMergedUpstreamBranches(t *testing.T) { cmd.Dir = repoPath cmd.Run() - // Find merged branches with work/ prefix - merged, err := manager.FindMergedUpstreamBranches("work/") + // Find merged branches with multiclaude/ prefix + merged, err := manager.FindMergedUpstreamBranches("multiclaude/") if err != nil { t.Fatalf("Unexpected error: %v", err) } - // Should only find work/test + // Should only find multiclaude/test for _, b := range merged { - if !strings.HasPrefix(b, "work/") { + if !strings.HasPrefix(b, "multiclaude/") { t.Errorf("Branch %s should not be included (wrong prefix)", b) } } @@ -1484,13 +1484,13 @@ func TestListBranchesWithPrefix(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/feature-1") - createBranch(t, repoPath, "work/feature-2") - createBranch(t, repoPath, "multiclaude/agent-1") + createBranch(t, repoPath, "multiclaude/feature-1") + createBranch(t, repoPath, "multiclaude/feature-2") + createBranch(t, repoPath, "workspace/agent-1") createBranch(t, repoPath, "other/branch") - // List work/ branches - branches, err := manager.ListBranchesWithPrefix("work/") + // List multiclaude/ branches + branches, err := manager.ListBranchesWithPrefix("multiclaude/") if err != nil { t.Fatalf("Failed to list branches: %v", err) } @@ -1499,19 +1499,19 @@ func TestListBranchesWithPrefix(t *testing.T) { t.Errorf("Expected 2 branches, got %d: %v", len(branches), branches) } - // Verify both work/ branches are in the list + // Verify both multiclaude/ branches are in the list foundFeature1 := false foundFeature2 := false for _, b := range branches { - if b == "work/feature-1" { + if b == "multiclaude/feature-1" { foundFeature1 = true } - if b == "work/feature-2" { + if b == "multiclaude/feature-2" { foundFeature2 = true } } if !foundFeature1 || !foundFeature2 { - t.Errorf("Expected to find work/feature-1 and work/feature-2, got: %v", branches) + t.Errorf("Expected to find multiclaude/feature-1 and multiclaude/feature-2, got: %v", branches) } }) @@ -1557,19 +1557,19 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches - createBranch(t, repoPath, "work/orphan-1") - createBranch(t, repoPath, "work/orphan-2") - createBranch(t, repoPath, "work/active") + createBranch(t, repoPath, "multiclaude/orphan-1") + createBranch(t, repoPath, "multiclaude/orphan-2") + createBranch(t, repoPath, "multiclaude/active") // Create a worktree for one branch wtPath := filepath.Join(repoPath, "wt-active") - if err := manager.Create(wtPath, "work/active"); err != nil { + if err := manager.Create(wtPath, "multiclaude/active"); err != nil { t.Fatalf("Failed to create worktree: %v", err) } defer manager.Remove(wtPath, true) // Find orphaned branches - orphaned, err := manager.FindOrphanedBranches("work/") + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } @@ -1580,7 +1580,7 @@ func TestFindOrphanedBranches(t *testing.T) { } for _, b := range orphaned { - if b == "work/active" { + if b == "multiclaude/active" { t.Error("Active branch should not be in orphaned list") } } @@ -1593,15 +1593,15 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and a worktree for it - createBranch(t, repoPath, "work/active") + createBranch(t, repoPath, "multiclaude/active") wtPath := filepath.Join(repoPath, "wt-active") - if err := manager.Create(wtPath, "work/active"); err != nil { + if err := manager.Create(wtPath, "multiclaude/active"); err != nil { t.Fatalf("Failed to create worktree: %v", err) } defer manager.Remove(wtPath, true) - orphaned, err := manager.FindOrphanedBranches("work/") + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } @@ -1618,21 +1618,21 @@ func TestFindOrphanedBranches(t *testing.T) { manager := NewManager(repoPath) // Create branches with different prefixes - createBranch(t, repoPath, "work/orphan") createBranch(t, repoPath, "multiclaude/orphan") + createBranch(t, repoPath, "workspace/orphan") - // Find orphaned branches with work/ prefix - orphaned, err := manager.FindOrphanedBranches("work/") + // Find orphaned branches with multiclaude/ prefix + orphaned, err := manager.FindOrphanedBranches("multiclaude/") if err != nil { t.Fatalf("Failed to find orphaned branches: %v", err) } - // Should only find work/orphan + // Should only find multiclaude/orphan if len(orphaned) != 1 { t.Errorf("Expected 1 orphaned branch, got %d: %v", len(orphaned), orphaned) } - if len(orphaned) > 0 && orphaned[0] != "work/orphan" { - t.Errorf("Expected work/orphan, got: %s", orphaned[0]) + if len(orphaned) > 0 && orphaned[0] != "multiclaude/orphan" { + t.Errorf("Expected multiclaude/orphan, got: %s", orphaned[0]) } }) } @@ -1765,10 +1765,10 @@ func TestCleanupMergedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a merged branch - createBranch(t, repoPath, "work/merged-test") + createBranch(t, repoPath, "multiclaude/merged-test") // Verify branch exists - exists, _ := manager.BranchExists("work/merged-test") + exists, _ := manager.BranchExists("multiclaude/merged-test") if !exists { t.Fatal("Branch should exist before cleanup") } @@ -1783,7 +1783,7 @@ func TestCleanupMergedBranches(t *testing.T) { cmd.Run() // Clean up merged branches - deleted, err := manager.CleanupMergedBranches("work/", false) + deleted, err := manager.CleanupMergedBranches("multiclaude/", false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1793,7 +1793,7 @@ func TestCleanupMergedBranches(t *testing.T) { } // Verify branch is deleted - exists, _ = manager.BranchExists("work/merged-test") + exists, _ = manager.BranchExists("multiclaude/merged-test") if exists { t.Error("Branch should be deleted after cleanup") } @@ -1806,12 +1806,12 @@ func TestCleanupMergedBranches(t *testing.T) { manager := NewManager(repoPath) // Create a branch and a worktree for it - createBranch(t, repoPath, "work/active-branch") + createBranch(t, repoPath, "multiclaude/active-branch") wtPath := filepath.Join(repoPath, "worktrees", "active") os.MkdirAll(filepath.Dir(wtPath), 0755) - err := manager.Create(wtPath, "work/active-branch") + err := manager.Create(wtPath, "multiclaude/active-branch") if err != nil { t.Fatalf("Failed to create worktree: %v", err) } @@ -1827,20 +1827,20 @@ func TestCleanupMergedBranches(t *testing.T) { cmd.Run() // Clean up merged branches - deleted, err := manager.CleanupMergedBranches("work/", false) + deleted, err := manager.CleanupMergedBranches("multiclaude/", false) if err != nil { t.Fatalf("Unexpected error: %v", err) } // The active branch should NOT be deleted for _, b := range deleted { - if b == "work/active-branch" { + if b == "multiclaude/active-branch" { t.Error("Active branch should not be deleted") } } // Verify branch still exists - exists, _ := manager.BranchExists("work/active-branch") + exists, _ := manager.BranchExists("multiclaude/active-branch") if !exists { t.Error("Active branch should still exist") } @@ -2845,21 +2845,21 @@ func TestDeleteRemoteBranch(t *testing.T) { } // Create and push a branch - createBranch(t, repoPath, "work/to-delete") - cmd = exec.Command("git", "push", "-u", "origin", "work/to-delete") + createBranch(t, repoPath, "multiclaude/to-delete") + cmd = exec.Command("git", "push", "-u", "origin", "multiclaude/to-delete") cmd.Dir = repoPath if err := cmd.Run(); err != nil { t.Fatalf("Failed to push branch: %v", err) } // Delete the remote branch - err = manager.DeleteRemoteBranch("origin", "work/to-delete") + err = manager.DeleteRemoteBranch("origin", "multiclaude/to-delete") if err != nil { t.Fatalf("DeleteRemoteBranch failed: %v", err) } // Verify branch was deleted from remote - cmd = exec.Command("git", "ls-remote", "--heads", "origin", "work/to-delete") + cmd = exec.Command("git", "ls-remote", "--heads", "origin", "multiclaude/to-delete") cmd.Dir = repoPath output, _ := cmd.Output() if len(output) > 0 { @@ -2948,10 +2948,10 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Create a merged branch - createBranch(t, repoPath, "work/merged-remote") + createBranch(t, repoPath, "multiclaude/merged-remote") // Push the branch - cmd = exec.Command("git", "push", "-u", "origin", "work/merged-remote") + cmd = exec.Command("git", "push", "-u", "origin", "multiclaude/merged-remote") cmd.Dir = repoPath if err := cmd.Run(); err != nil { t.Fatalf("Failed to push branch: %v", err) @@ -2965,7 +2965,7 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Clean up merged branches with remote deletion - deleted, err := manager.CleanupMergedBranches("work/", true) + deleted, err := manager.CleanupMergedBranches("multiclaude/", true) if err != nil { t.Fatalf("CleanupMergedBranches failed: %v", err) } @@ -2975,13 +2975,13 @@ func TestCleanupMergedBranchesWithRemoteDeletion(t *testing.T) { } // Verify local branch is deleted - exists, _ := manager.BranchExists("work/merged-remote") + exists, _ := manager.BranchExists("multiclaude/merged-remote") if exists { t.Error("Local branch should be deleted") } // Verify remote branch is deleted - cmd = exec.Command("git", "ls-remote", "--heads", "origin", "work/merged-remote") + cmd = exec.Command("git", "ls-remote", "--heads", "origin", "multiclaude/merged-remote") cmd.Dir = repoPath output, _ := cmd.Output() if len(output) > 0 { diff --git a/test/e2e_test.go b/test/e2e_test.go index 5a37506..f5c09e1 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -170,7 +170,7 @@ func TestPhase2Integration(t *testing.T) { // Create worktree wt := worktree.NewManager(repoPath) - if err := wt.CreateNewBranch(workerPath, "work/test-worker", "HEAD"); err != nil { + if err := wt.CreateNewBranch(workerPath, "multiclaude/test-worker", "HEAD"); err != nil { t.Fatalf("Failed to create worktree: %v", err) }