From 8d5eb49adcd5731586bee7caf054b9a453169721 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 01:12:03 +0000 Subject: [PATCH 01/15] docs: add extensibility hints to CLI, daemon, state --- internal/cli/cli.go | 6 +++++- internal/daemon/daemon.go | 6 +++++- internal/state/state.go | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a75ef30..ed3caf1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5123,7 +5123,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 and extension docs. When +// adding or changing commands/flags, ensure docs/EXTENSION_DOCUMENTATION_SUMMARY.md +// stays 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/daemon/daemon.go b/internal/daemon/daemon.go index f430ccc..134daf0 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -571,7 +571,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) diff --git a/internal/state/state.go b/internal/state/state.go index 5e524e2..2030d82 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -141,6 +141,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"` From 09d1f9ca683e1161c6a5cc5d47258a616c2a8c00 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 01:29:57 +0000 Subject: [PATCH 02/15] docs: align extension docs with code and add verify gate --- .github/workflows/verify-docs.yml | 23 + CLAUDE.md | 20 +- cmd/verify-docs/main.go | 542 +++++++--- docs/EXTENSIBILITY.md | 416 +------- docs/EXTENSION_DOCUMENTATION_SUMMARY.md | 282 +---- docs/extending/EVENT_HOOKS.md | 906 +--------------- docs/extending/SOCKET_API.md | 1204 ++-------------------- docs/extending/STATE_FILE_INTEGRATION.md | 782 ++------------ docs/extending/WEB_UI_DEVELOPMENT.md | 987 +----------------- 9 files changed, 604 insertions(+), 4558 deletions(-) create mode 100644 .github/workflows/verify-docs.yml 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 c732cd3..7824209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,34 +208,32 @@ Multiclaude is designed for extension **without modifying the core binary**. Ext | Extension Point | Use Cases | Documentation | |----------------|-----------|---------------| | **State File** | Monitoring, dashboards, analytics | [`docs/extending/STATE_FILE_INTEGRATION.md`](docs/extending/STATE_FILE_INTEGRATION.md) | -| **Event Hooks** | Notifications, webhooks, alerting | [`docs/extending/EVENT_HOOKS.md`](docs/extending/EVENT_HOOKS.md) | | **Socket API** | Custom CLIs, automation, control planes | [`docs/extending/SOCKET_API.md`](docs/extending/SOCKET_API.md) | -| **Web UIs** | Visual monitoring dashboards | [`docs/extending/WEB_UI_DEVELOPMENT.md`](docs/extending/WEB_UI_DEVELOPMENT.md) | +| **Event Hooks** | [PLANNED] (not implemented) | [`docs/extending/EVENT_HOOKS.md`](docs/extending/EVENT_HOOKS.md) | +| **Web UIs** | [PLANNED] (not implemented) | [`docs/extending/WEB_UI_DEVELOPMENT.md`](docs/extending/WEB_UI_DEVELOPMENT.md) | **Start here:** [`docs/EXTENSIBILITY.md`](docs/EXTENSIBILITY.md) - Complete extension guide ### For LLMs: Keeping Extension Docs Updated -**CRITICAL:** When modifying multiclaude core, check if extension documentation needs updates: +**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/main.go` (when implemented) + - Run: `go run ./cmd/verify-docs` -2. **Event Type Changes** (`internal/events/events.go`) - - Update: [`docs/extending/EVENT_HOOKS.md`](docs/extending/EVENT_HOOKS.md) - - Update event type table - - Update event JSON format examples - - Add new event examples if new types added - -3. **Socket Command Changes** (`internal/daemon/daemon.go`) +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 +3. **Event Type Changes** (`internal/events/events.go`) *(only after events exist)* + - Update: [`docs/extending/EVENT_HOOKS.md`](docs/extending/EVENT_HOOKS.md) + - Mark planned items as `[PLANNED]` until implemented + 4. **Runtime Directory Changes** (`pkg/config/config.go`) - Update: All extension docs that reference file paths - Update the "Runtime Directories" section below diff --git a/cmd/verify-docs/main.go b/cmd/verify-docs/main.go index d0c8d60..cfc46e7 100644 --- a/cmd/verify-docs/main.go +++ b/cmd/verify-docs/main.go @@ -13,14 +13,16 @@ package main import ( - "bufio" "flag" "fmt" "go/ast" "go/parser" "go/token" "os" + "reflect" "regexp" + "sort" + "strconv" "strings" ) @@ -77,20 +79,198 @@ func main() { } } -// verifyStateSchema checks that state.State fields are documented +// verifyStateSchema checks that state structs/fields match the docs list. func verifyStateSchema() Verification { v := Verification{Name: "State schema documentation"} - // Parse internal/state/state.go + codeStructs, err := parseStateStructsFromCode() + if err != nil { + v.Message = err.Error() + return v + } + + docStructs, err := parseStateStructsFromDocs() + if err != nil { + v.Message = err.Error() + return v + } + + missingStructs := diffKeys(codeStructs, docStructs) + extraStructs := diffKeys(docStructs, codeStructs) + + var missingFields []string + var extraFields []string + + for name, fields := range codeStructs { + docFields := docStructs[name] + missingFields = append(missingFields, diffListPrefixed(fields, docFields, name)...) + extraFields = append(extraFields, diffListPrefixed(docFields, fields, name)...) + } + + if len(missingStructs) > 0 || len(extraStructs) > 0 || len(missingFields) > 0 || len(extraFields) > 0 { + var parts []string + if len(missingStructs) > 0 { + parts = append(parts, fmt.Sprintf("missing structs: %s", strings.Join(missingStructs, ", "))) + } + if len(extraStructs) > 0 { + parts = append(parts, fmt.Sprintf("undocumented structs removed from code: %s", strings.Join(extraStructs, ", "))) + } + if len(missingFields) > 0 { + parts = append(parts, fmt.Sprintf("missing fields: %s", strings.Join(missingFields, ", "))) + } + if len(extraFields) > 0 { + parts = append(parts, fmt.Sprintf("fields documented but not in code: %s", strings.Join(extraFields, ", "))) + } + v.Message = strings.Join(parts, "; ") + return v + } + + v.Passed = true + return v +} + +// verifyEventTypes checks that all event types are documented and not hallucinated. +func verifyEventTypes() Verification { + v := Verification{Name: "Event types documentation"} + + codeEvents, eventsFilePresent, err := parseEventsFromCode() + if err != nil { + v.Message = err.Error() + return v + } + + docEvents, docStatus, err := parseEventsFromDocs() + if err != nil { + v.Message = err.Error() + return v + } + + if !eventsFilePresent { + if docStatus != "planned" && len(docEvents) > 0 { + v.Message = "events not implemented in code; docs must mark as [PLANNED] and avoid listing concrete event types" + return v + } + v.Passed = true + return v + } + + missing := diffList(codeEvents, docEvents) + extra := diffList(docEvents, codeEvents) + + if len(missing) > 0 || len(extra) > 0 { + var parts []string + if len(missing) > 0 { + parts = append(parts, fmt.Sprintf("missing events: %s", strings.Join(missing, ", "))) + } + if len(extra) > 0 { + parts = append(parts, fmt.Sprintf("events documented but not in code: %s", strings.Join(extra, ", "))) + } + v.Message = strings.Join(parts, "; ") + return v + } + + v.Passed = true + return v +} + +// verifySocketCommands checks that socket commands in code and docs are aligned. +func verifySocketCommands() Verification { + v := Verification{Name: "Socket commands documentation"} + + codeCommands, err := parseSocketCommandsFromCode() + if err != nil { + v.Message = err.Error() + return v + } + + docCommands, err := parseSocketCommandsFromDocs() + if err != nil { + v.Message = err.Error() + return v + } + + missing := diffList(codeCommands, docCommands) + extra := diffList(docCommands, codeCommands) + + if len(missing) > 0 || len(extra) > 0 { + var parts []string + if len(missing) > 0 { + parts = append(parts, fmt.Sprintf("missing commands: %s", strings.Join(missing, ", "))) + } + if len(extra) > 0 { + parts = append(parts, fmt.Sprintf("commands documented but not in code: %s", strings.Join(extra, ", "))) + } + v.Message = strings.Join(parts, "; ") + return v + } + + v.Passed = true + return v +} + +// verifyFilePaths checks that file paths mentioned in docs exist. +func verifyFilePaths() Verification { + v := Verification{Name: "File path references"} + + docFiles := []string{ + "docs/EXTENSIBILITY.md", + "docs/extending/STATE_FILE_INTEGRATION.md", + "docs/extending/EVENT_HOOKS.md", + "docs/extending/WEB_UI_DEVELOPMENT.md", + "docs/extending/SOCKET_API.md", + } + + filePattern := regexp.MustCompile("`((?:internal|pkg|cmd)/[^`]+\\.go)`") + + missing := []string{} + + for _, docFile := range docFiles { + content, err := os.ReadFile(docFile) + if err != nil { + continue // Skip missing docs + } + + matches := filePattern.FindAllStringSubmatch(string(content), -1) + for _, match := range matches { + if len(match) > 1 { + filePath := match[1] + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + missing = append(missing, fmt.Sprintf("%s (referenced in %s)", filePath, docFile)) + } + } + } + } + + if len(missing) > 0 { + v.Message = fmt.Sprintf("Missing files:\n %s", strings.Join(missing, "\n ")) + return v + } + + v.Passed = true + return v +} + +// parseStateStructsFromCode extracts json field names for tracked structs. +func parseStateStructsFromCode() (map[string][]string, error) { + tracked := map[string]struct{}{ + "State": {}, + "Repository": {}, + "Agent": {}, + "TaskHistoryEntry": {}, + "MergeQueueConfig": {}, + "PRShepherdConfig": {}, + "ForkConfig": {}, + } + fset := token.NewFileSet() node, err := parser.ParseFile(fset, "internal/state/state.go", nil, parser.ParseComments) if err != nil { - v.Message = fmt.Sprintf("Failed to parse state.go: %v", err) - return v + return nil, fmt.Errorf("failed to parse state.go: %w", err) } - // Find struct definitions structs := make(map[string][]string) + ast.Inspect(node, func(n ast.Node) bool { typeSpec, ok := n.(*ast.TypeSpec) if !ok { @@ -102,84 +282,84 @@ func verifyStateSchema() Verification { return true } - fields := []string{} + if _, wanted := tracked[typeSpec.Name.Name]; !wanted { + return true + } + + var fields []string for _, field := range structType.Fields.List { + // skip embedded or unexported fields + if len(field.Names) == 0 { + continue + } for _, name := range field.Names { - // Skip private fields if !ast.IsExported(name.Name) { continue } - fields = append(fields, name.Name) + + jsonName := jsonTag(field) + if jsonName == "" { + jsonName = toSnakeCase(name.Name) + } + if jsonName == "-" || jsonName == "" { + continue + } + fields = append(fields, jsonName) } } - structs[typeSpec.Name.Name] = fields + structs[typeSpec.Name.Name] = uniqueSorted(fields) return true }) - // Check important structs are documented - importantStructs := []string{ - "State", - "Repository", - "Agent", - "TaskHistoryEntry", - "MergeQueueConfig", - "HookConfig", - } + return structs, nil +} +// parseStateStructsFromDocs reads state struct definitions from marker comments. +func parseStateStructsFromDocs() (map[string][]string, error) { docFile := "docs/extending/STATE_FILE_INTEGRATION.md" - docContent, err := os.ReadFile(docFile) + content, err := os.ReadFile(docFile) if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) - return v + return nil, fmt.Errorf("failed to read %s: %w", docFile, err) } - missing := []string{} - for _, structName := range importantStructs { - if *verbose { - fmt.Printf(" Checking struct: %s\n", structName) - } + pattern := regexp.MustCompile(`(?m)`) + matches := pattern.FindAllStringSubmatch(string(content), -1) - // Check if struct name appears in docs - if !strings.Contains(string(docContent), structName) { - missing = append(missing, structName) + structs := make(map[string][]string) + for _, m := range matches { + if len(m) < 3 { continue } - - // Check if fields are documented (basic check) - fields := structs[structName] - for _, field := range fields { - // Convert field name to JSON format (snake_case) - jsonField := toSnakeCase(field) - if !strings.Contains(string(docContent), fmt.Sprintf(`"%s"`, jsonField)) { - missing = append(missing, fmt.Sprintf("%s.%s", structName, field)) - } - } + name := strings.TrimSpace(m[1]) + fields := uniqueSorted(strings.Fields(m[2])) + structs[name] = fields } - if len(missing) > 0 { - v.Message = fmt.Sprintf("Missing or incomplete: %s", strings.Join(missing, ", ")) - return v + if len(structs) == 0 { + return nil, fmt.Errorf("no state-struct markers found in %s", docFile) } - v.Passed = true - return v + return structs, nil } -// verifyEventTypes checks that all event types are documented -func verifyEventTypes() Verification { - v := Verification{Name: "Event types documentation"} +// parseEventsFromCode returns event names and a flag indicating if the file exists. +func parseEventsFromCode() ([]string, bool, error) { + path := "internal/events/events.go" + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("failed to stat %s: %w", path, err) + } - // Parse internal/events/events.go fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "internal/events/events.go", nil, parser.ParseComments) + node, err := parser.ParseFile(fset, path, nil, 0) if err != nil { - v.Message = fmt.Sprintf("Failed to parse events.go: %v", err) - return v + return nil, true, fmt.Errorf("failed to parse %s: %w", path, err) } - // Find EventType constants - eventTypes := []string{} + var events []string ast.Inspect(node, func(n ast.Node) bool { genDecl, ok := n.(*ast.GenDecl) if !ok || genDecl.Tok != token.CONST { @@ -194,154 +374,188 @@ func verifyEventTypes() Verification { for _, name := range valueSpec.Names { if strings.HasPrefix(name.Name, "Event") { - eventTypes = append(eventTypes, name.Name) + events = append(events, name.Name) } } } - return true }) - // Check if documented + return uniqueSorted(events), true, nil +} + +// parseEventsFromDocs reads event names (or planned status) from marker comments. +func parseEventsFromDocs() ([]string, string, error) { docFile := "docs/extending/EVENT_HOOKS.md" - docContent, err := os.ReadFile(docFile) + content, err := os.ReadFile(docFile) if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) - return v + return nil, "", fmt.Errorf("failed to read %s: %w", docFile, err) } - missing := []string{} - for _, eventType := range eventTypes { - // Extract the actual event type string (e.g., EventAgentStarted -> agent_started) - // This is a simplified check - we just check if the constant name appears - if !strings.Contains(string(docContent), eventType) { - missing = append(missing, eventType) - } + matches := regexp.MustCompile(`(?m)`).FindStringSubmatch(string(content)) + if len(matches) < 2 { + return nil, "", fmt.Errorf("no events marker found in %s", docFile) } - if len(missing) > 0 { - v.Message = fmt.Sprintf("Undocumented event types: %s", strings.Join(missing, ", ")) - return v + raw := strings.Fields(matches[1]) + if len(raw) == 1 && strings.EqualFold(raw[0], "planned") { + return nil, "planned", nil } - v.Passed = true - return v + return uniqueSorted(raw), "", nil } -// verifySocketCommands checks that all socket commands are documented -func verifySocketCommands() Verification { - v := Verification{Name: "Socket commands documentation"} - - // Find all case statements in handleRequest - commands := []string{} - - file, err := os.Open("internal/daemon/daemon.go") +// parseSocketCommandsFromCode extracts socket commands from handleRequest. +func parseSocketCommandsFromCode() ([]string, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "internal/daemon/daemon.go", nil, 0) if err != nil { - v.Message = fmt.Sprintf("Failed to open daemon.go: %v", err) - return v + return nil, fmt.Errorf("failed to parse daemon.go: %w", err) } - defer file.Close() - - scanner := bufio.NewScanner(file) - inSwitch := false - casePattern := regexp.MustCompile(`case\s+"([^"]+)":`) - for scanner.Scan() { - line := scanner.Text() + var commands []string - if strings.Contains(line, "switch req.Command") { - inSwitch = true - continue + ast.Inspect(node, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name == nil || fn.Name.Name != "handleRequest" { + return true } - if inSwitch { - if strings.Contains(line, "default:") { - break + ast.Inspect(fn.Body, func(n ast.Node) bool { + sw, ok := n.(*ast.SwitchStmt) + if !ok || !isReqCommand(sw.Tag) { + return true } - matches := casePattern.FindStringSubmatch(line) - if len(matches) > 1 { - commands = append(commands, matches[1]) + for _, stmt := range sw.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + cmd, err := strconv.Unquote(lit.Value) + if err == nil && cmd != "" { + commands = append(commands, cmd) + } + } } - } - } + return true + }) + return false + }) + + return uniqueSorted(commands), nil +} - // Check if documented +// parseSocketCommandsFromDocs reads socket command list from marker comments. +func parseSocketCommandsFromDocs() ([]string, error) { docFile := "docs/extending/SOCKET_API.md" - docContent, err := os.ReadFile(docFile) + content, err := os.ReadFile(docFile) if err != nil { - v.Message = fmt.Sprintf("Failed to read %s: %v", docFile, err) - return v + return nil, fmt.Errorf("failed to read %s: %w", docFile, err) } - missing := []string{} - for _, cmd := range commands { - // Check for command in documentation (should appear as "#### command_name") - if !strings.Contains(string(docContent), cmd) { - missing = append(missing, cmd) - } + list := parseListFromComment(string(content), "socket-commands") + if len(list) == 0 { + return nil, fmt.Errorf("no socket-commands marker found in %s", docFile) } + return list, nil +} - if len(missing) > 0 { - v.Message = fmt.Sprintf("Undocumented commands: %s", strings.Join(missing, ", ")) - return v +// parseListFromComment extracts a newline-delimited list from an HTML comment label. +func parseListFromComment(content, label string) []string { + pattern := fmt.Sprintf("(?s)", regexp.QuoteMeta(label)) + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(content) + if len(matches) < 2 { + return nil } - v.Passed = true - return v + var items []string + for _, line := range strings.Split(matches[1], "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + items = append(items, line) + } + return uniqueSorted(items) } -// verifyFilePaths checks that file paths mentioned in docs exist -func verifyFilePaths() Verification { - v := Verification{Name: "File path references"} - - // Check all extension docs - docFiles := []string{ - "docs/EXTENSIBILITY.md", - "docs/extending/STATE_FILE_INTEGRATION.md", - "docs/extending/EVENT_HOOKS.md", - "docs/extending/WEB_UI_DEVELOPMENT.md", - "docs/extending/SOCKET_API.md", +// isReqCommand checks if the switch tag is req.Command. +func isReqCommand(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return false } + id, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return id.Name == "req" && sel.Sel != nil && sel.Sel.Name == "Command" +} - // Patterns to find file references - // Looking for things like: - // - `internal/state/state.go` - // - `cmd/multiclaude-web/main.go` - // - `pkg/config/config.go` - filePattern := regexp.MustCompile("`((?:internal|pkg|cmd)/[^`]+\\.go)`") - - missing := []string{} - - for _, docFile := range docFiles { - content, err := os.ReadFile(docFile) - if err != nil { - continue // Skip missing docs - } - - matches := filePattern.FindAllStringSubmatch(string(content), -1) - for _, match := range matches { - if len(match) > 1 { - filePath := match[1] +// jsonTag returns the json tag value if present. +func jsonTag(field *ast.Field) string { + if field.Tag == nil { + return "" + } + raw := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(raw).Get("json") + if tag == "" { + return "" + } + parts := strings.Split(tag, ",") + if len(parts) == 0 { + return "" + } + return parts[0] +} - // Check if file exists - if _, err := os.Stat(filePath); os.IsNotExist(err) { - missing = append(missing, fmt.Sprintf("%s (referenced in %s)", filePath, docFile)) - } - } +// diffList returns items in a but not in b. +func diffList(a, b []string) []string { + setB := make(map[string]struct{}, len(b)) + for _, item := range b { + setB[item] = struct{}{} + } + var diff []string + for _, item := range a { + if _, ok := setB[item]; !ok { + diff = append(diff, item) } } + return uniqueSorted(diff) +} - if len(missing) > 0 { - v.Message = fmt.Sprintf("Missing files:\n %s", strings.Join(missing, "\n ")) - return v +// diffListPrefixed returns items in a but not in b, prefixed with struct name. +func diffListPrefixed(a, b []string, prefix string) []string { + items := diffList(a, b) + for i, item := range items { + items[i] = fmt.Sprintf("%s.%s", prefix, item) } + return items +} - v.Passed = true - return v +// diffKeys returns keys in a but not in b. +func diffKeys(a, b map[string][]string) []string { + keysB := make(map[string]struct{}, len(b)) + for k := range b { + keysB[k] = struct{}{} + } + var diff []string + for k := range a { + if _, ok := keysB[k]; !ok { + diff = append(diff, k) + } + } + return uniqueSorted(diff) } -// toSnakeCase converts PascalCase to snake_case +// toSnakeCase converts PascalCase to snake_case. func toSnakeCase(s string) string { var result []rune for i, r := range s { @@ -352,3 +566,19 @@ func toSnakeCase(s string) string { } return strings.ToLower(string(result)) } + +// uniqueSorted returns a sorted unique copy of the slice. +func uniqueSorted(items []string) []string { + set := make(map[string]struct{}, len(items)) + for _, item := range items { + set[item] = struct{}{} + } + + out := make([]string, 0, len(set)) + for item := range set { + out = append(out, item) + } + + sort.Strings(out) + return out +} diff --git a/docs/EXTENSIBILITY.md b/docs/EXTENSIBILITY.md index a544be9..b8808bf 100644 --- a/docs/EXTENSIBILITY.md +++ b/docs/EXTENSIBILITY.md @@ -1,407 +1,19 @@ -# Multiclaude Extensibility Guide +# Extensibility (Current State) -> **NOTE: IMPLEMENTATION STATUS VARIES** -> -> Some extension points documented here are **not fully implemented**: -> - **State File**: ✅ Implemented - works as documented -> - **Event Hooks**: ❌ NOT IMPLEMENTED - `multiclaude hooks` command does not exist -> - **Socket API**: ⚠️ Partially implemented - some commands may not exist -> - **Web UI Reference**: ❌ NOT IMPLEMENTED - `cmd/multiclaude-web` does not exist -> -> Per ROADMAP.md, web interfaces and notification systems are **out of scope** for upstream multiclaude. -> These docs are preserved for fork implementations. +Multiclaude can be extended today through two supported surfaces: -**Target Audience:** Future LLMs and developers building extensions for multiclaude +| Extension Point | Capabilities | Docs | +|-----------------|--------------|------| +| **State file** (`~/.multiclaude/state.json`) | Read-only monitoring and analytics | [`docs/extending/STATE_FILE_INTEGRATION.md`](extending/STATE_FILE_INTEGRATION.md) | +| **Socket API** (`~/.multiclaude/daemon.sock`) | Programmatic control via the daemon | [`docs/extending/SOCKET_API.md`](extending/SOCKET_API.md) | -This guide documents how to extend multiclaude **without modifying the core binary**. Multiclaude is designed with a clean separation between core orchestration and external integrations, allowing downstream projects to build custom notifications, web UIs, monitoring tools, and more. +The following are **planned, not implemented**: Event hooks, first-party web UI. They are explicitly marked as [PLANNED] to prevent hallucinations. -## Philosophy +## Quick-start patterns +- Build dashboards or metrics exporters by polling the state file (no daemon interaction needed). +- Build automation or alternative CLIs by sending JSON requests to the socket API commands that exist in `internal/daemon/daemon.go`. -**Zero-Modification Extension:** Multiclaude provides clean interfaces for external tools: -- **State File**: Read-only JSON state for monitoring and visualization (✅ IMPLEMENTED) -- **Event Hooks**: Execute custom scripts on lifecycle events (❌ NOT IMPLEMENTED) -- **Socket API**: Programmatic control via Unix socket IPC (⚠️ PARTIAL) -- **File System**: Standard directories for messages, logs, and worktrees (✅ IMPLEMENTED) - -**Fork-Friendly Architecture:** Extensions that upstream rejects (web UIs, notifications) can be maintained in forks without conflicts, as they operate entirely outside the core binary. - -## Extension Points Overview - -| Extension Point | Use Cases | Read | Write | Complexity | -|----------------|-----------|------|-------|------------| -| **State File** | Monitoring, dashboards, analytics | ✓ | ✗ | Low | -| **Event Hooks** | Notifications, webhooks, alerting | ✓ | ✗ | Low | -| **Socket API** | Custom CLIs, automation, control planes | ✓ | ✓ | Medium | -| **File System** | Log parsing, message injection, debugging | ✓ | ⚠️ | Low | -| **Public Packages** | Embedded orchestration, custom runners | ✓ | ✓ | High | - -## Quick Start for Common Use Cases - -### Building a Notification Integration - -**Goal:** Send Slack/Discord/email alerts when PRs are created, CI fails, etc. - -**Best Approach:** Event Hooks (see [`EVENT_HOOKS.md`](extending/EVENT_HOOKS.md)) - -**Why:** Fire-and-forget design, no dependencies, user-controlled scripts. - -**Example:** -```bash -# Configure hook -multiclaude hooks set on_pr_created /usr/local/bin/notify-slack.sh - -# Your hook receives JSON via stdin -# { -# "type": "pr_created", -# "timestamp": "2024-01-15T10:30:00Z", -# "repo_name": "my-repo", -# "agent_name": "clever-fox", -# "data": {"pr_number": 42, "title": "...", "url": "..."} -# } -``` - -### Building a Web Dashboard - -**Goal:** Visual monitoring of agents, tasks, and PRs across repositories. - -**Best Approach:** State File Reader + Web Server (see [`WEB_UI_DEVELOPMENT.md`](extending/WEB_UI_DEVELOPMENT.md)) - -**Why:** Read-only, no daemon interaction needed, simple architecture. - -**Reference Implementation:** `cmd/multiclaude-web/` - Full web dashboard in <500 LOC - -**Architecture Pattern:** -``` -┌──────────────┐ -│ state.json │ ← Atomic writes by daemon -└──────┬───────┘ - │ (fsnotify watch) -┌──────▼───────┐ -│ StateReader │ ← Your tool -└──────┬───────┘ - │ -┌──────▼───────┐ -│ HTTP Server │ ← REST API + SSE for live updates -└──────────────┘ -``` - -### Building Custom Automation - -**Goal:** Programmatically spawn workers, query status, manage repos. - -**Best Approach:** Socket API client (see [`SOCKET_API.md`](extending/SOCKET_API.md)) - -**Why:** Full control plane access, structured request/response. - -**Example:** -```go -client := socket.NewClient("~/.multiclaude/daemon.sock") -resp, err := client.Send(socket.Request{ - Command: "spawn_worker", - Args: map[string]interface{}{ - "repo": "my-repo", - "task": "Add authentication", - }, -}) -``` - -### Building Analytics/Reporting - -**Goal:** Task history analysis, success rates, PR metrics. - -**Best Approach:** State File Reader (see [`STATE_FILE_INTEGRATION.md`](extending/STATE_FILE_INTEGRATION.md)) - -**Why:** Complete historical data, no daemon dependency, simple JSON parsing. - -**Schema:** -```json -{ - "repos": { - "my-repo": { - "task_history": [ - { - "name": "clever-fox", - "task": "...", - "status": "merged", - "pr_url": "...", - "created_at": "...", - "completed_at": "..." - } - ] - } - } -} -``` - -## Extension Categories - -### 1. Read-Only Monitoring Tools - -**Characteristics:** -- No daemon interaction required -- Watch `state.json` with `fsnotify` or periodic polling -- Zero risk of breaking multiclaude operation -- Can run on different machines (via file sharing or SSH) - -**Examples:** -- Web dashboards (`multiclaude-web`) -- CLI status monitors -- Metrics exporters (Prometheus, Datadog) -- Log aggregators - -**See:** [`STATE_FILE_INTEGRATION.md`](extending/STATE_FILE_INTEGRATION.md) - -### 2. Event-Driven Integrations - -**Characteristics:** -- Daemon emits events → your script executes -- Fire-and-forget (30s timeout, no retries) -- User-controlled, zero core dependencies -- Ideal for notifications and webhooks - -**Examples:** -- Slack/Discord/email notifications -- GitHub status updates -- Custom alerting systems -- Audit logging - -**See:** [`EVENT_HOOKS.md`](extending/EVENT_HOOKS.md) - -### 3. Control Plane Tools - -**Characteristics:** -- Read and write via socket API -- Structured JSON request/response -- Full programmatic control -- Requires daemon to be running - -**Examples:** -- Custom CLIs (alternative to `multiclaude` binary) -- IDE integrations -- CI/CD orchestration -- Workflow automation tools - -**See:** [`SOCKET_API.md`](extending/SOCKET_API.md) - -### 4. Embedded Orchestration - -**Characteristics:** -- Import multiclaude as a Go library -- Use public packages: `pkg/claude`, `pkg/tmux`, `pkg/config` -- Build custom orchestrators with multiclaude primitives -- Maximum flexibility, maximum complexity - -**Examples:** -- Custom multi-agent systems -- Alternative daemon implementations -- Testing frameworks - -**See:** Package documentation in `pkg/*/README.md` - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ External Extensions (Your Tools) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Web Dashboard│ │ Slack Notif. │ │ Custom CLI │ │ -│ │ (fsnotify) │ │ (hook script)│ │ (socket API) │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -└─────────┼──────────────────┼──────────────────┼─────────────────┘ - │ │ │ - │ READ │ EXECUTE │ READ/WRITE - │ │ │ -┌─────────▼──────────────────▼──────────────────▼─────────────────┐ -│ Multiclaude Core │ -│ │ -│ state.json Hooks Config daemon.sock │ -│ (atomic writes) (in state) (Unix socket) │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Daemon │ │ Event │ │ Socket │ │ -│ │ Loops │───▶│ Bus │ │ Server │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -## File System Layout for Extensions - -Extensions interact with multiclaude through the `~/.multiclaude/` directory: - -``` -~/.multiclaude/ -├── state.json # Primary extension point - READ ONLY -│ # Contains all repos, agents, task history -│ # Updated atomically by daemon -│ -├── daemon.sock # Socket API - READ/WRITE -│ # Unix socket for programmatic control -│ -├── daemon.log # Daemon logs - READ ONLY -│ # Useful for debugging, monitoring -│ -├── output// # Agent output logs - READ ONLY -│ ├── supervisor.log -│ ├── merge-queue.log -│ └── workers/ -│ └── .log -│ -├── messages/// # Inter-agent messages - READ ONLY -│ └── .json # (Write only via socket API) -│ -├── wts/// # Git worktrees - ADVANCED ONLY -│ # Direct access not recommended -│ -└── repos// # Cloned repositories - READ ONLY -``` - -**Key Principles:** -1. **State File is Source of Truth**: Always read from `state.json` for current state -2. **Never Write Directly**: Use socket API for any state modifications -3. **Watch, Don't Poll Aggressively**: Use `fsnotify` for state changes -4. **Logs are Append-Only**: Safe to tail, but daemon manages rotation - -## Best Practices - -### For Future LLMs Building Extensions - -When modifying multiclaude core and asked to update extension documentation: - -1. **Check State Schema Changes** - - If `internal/state/state.go` types change, update [`STATE_FILE_INTEGRATION.md`](extending/STATE_FILE_INTEGRATION.md) - - Run `go generate ./docs` (when verification tool is implemented) - -2. **Check Event Type Changes** - - If `internal/events/events.go` adds/removes events, update [`EVENT_HOOKS.md`](extending/EVENT_HOOKS.md) - - Update event table with new hooks - -3. **Check Socket API Changes** - - If `internal/daemon/daemon.go` handler adds commands, update [`SOCKET_API.md`](extending/SOCKET_API.md) - - Document new request/response formats - -4. **Check Directory Layout Changes** - - If `pkg/config/config.go` paths change, update all extension docs - - Verify examples still work - -5. **Add Examples for New Features** - - New feature → new extension example - - Show how external tools should interact - -### For Extension Developers - -1. **Start Read-Only** - - Build monitoring/dashboards before control tools - - Less risk, easier to debug - -2. **Use Existing Patterns** - - Copy `cmd/multiclaude-web/` structure for dashboards - - Copy `examples/hooks/slack-notify.sh` for notifications - - Copy socket client examples for automation - -3. **Handle Daemon Restarts** - - State file persists across restarts - - Socket reconnection logic required - - Event hooks re-register automatically - -4. **Don't Block the Daemon** - - Event hooks timeout after 30s - - Socket requests should be fast - - Heavy processing in background threads - -5. **Respect Atomic Operations** - - State file writes are atomic (temp + rename) - - You may read during writes (you'll get old or new, never corrupt) - - Watch for WRITE events, ignore CREATE/CHMOD/etc. - -## Testing Your Extension - -### Without a Real Daemon - -```bash -# Create fake state for testing -cat > /tmp/test-state.json < **WARNING: THIS FEATURE IS NOT IMPLEMENTED** -> -> This document describes a **planned feature** that does not exist in the current codebase. -> The `multiclaude hooks` command does not exist, and `internal/events/events.go` has not been implemented. -> Per ROADMAP.md, notification systems are explicitly out of scope for upstream multiclaude. -> -> This document is preserved for potential fork implementations. + -**Extension Point:** Event-driven notifications via hook scripts (PLANNED - NOT IMPLEMENTED) +An event hook system is **not implemented** in the current codebase. When (and if) events are added, document the real payloads here and keep the list above in sync. Until then, downstream tools should not rely on events. -This guide documents a **planned** event system for building notification integrations (Slack, Discord, email, custom webhooks) using hook scripts. - -## Overview - -Multiclaude emits events at key lifecycle points and executes user-provided hook scripts when these events occur. Your hook receives event data as JSON via stdin and can do anything: send notifications, update external systems, trigger workflows, log to external services. - -**Philosophy:** -- **Hook-based, not built-in**: Notifications belong in user scripts, not core daemon -- **Fire-and-forget**: No retries, no delivery guarantees (hooks timeout after 30s) -- **Zero dependencies**: Core only emits events; notification logic is yours -- **Unix philosophy**: multiclaude emits JSON, you compose the rest - -## Event Types - -| Event Type | When It Fires | Common Use Cases | -|------------|---------------|------------------| -| `agent_started` | Agent starts (supervisor, worker, merge-queue, etc.) | Log agent activity, send startup notifications | -| `agent_stopped` | Agent stops (completed, failed, or killed) | Track completion, alert on failures | -| `agent_idle` | Agent has been idle for a threshold period | Detect stuck workers, send reminders | -| `agent_failed` | Agent crashes or fails | Alert on-call, create incidents | -| `pr_created` | Worker creates a PR | Notify team, update project board | -| `pr_merged` | PR is merged | Celebrate wins, update metrics | -| `pr_closed` | PR is closed without merging | Track rejected work | -| `task_assigned` | Task is assigned to a worker | Track work distribution | -| `task_complete` | Task completes (success or failure) | Update project management tools | -| `ci_failed` | CI checks fail on a PR | Alert author, create follow-up task | -| `ci_passed` | CI checks pass on a PR | Auto-merge if configured | -| `worker_stuck` | Worker hasn't made progress in N minutes | Alert supervisor, offer to restart | -| `message_sent` | Inter-agent message is sent | Debug message flow, log conversations | - -## Event JSON Format - -All hooks receive events via stdin as JSON: - -```json -{ - "type": "pr_created", - "timestamp": "2024-01-15T10:30:00Z", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "pr_number": 42, - "title": "Add user authentication", - "url": "https://github.com/user/repo/pull/42" - } -} -``` - -### Common Fields - -- `type` (string): Event type (see table above) -- `timestamp` (ISO 8601 string): When the event occurred -- `repo_name` (string, optional): Repository name (if event is repo-specific) -- `agent_name` (string, optional): Agent name (if event is agent-specific) -- `data` (object): Event-specific data - -### Event-Specific Data - -#### agent_started - -```json -{ - "type": "agent_started", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "agent_type": "worker", - "task": "Add user authentication" - } -} -``` - -#### agent_stopped - -```json -{ - "type": "agent_stopped", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "reason": "completed" // "completed" | "failed" | "killed" - } -} -``` - -#### agent_idle - -```json -{ - "type": "agent_idle", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "duration_seconds": 1800 // 30 minutes - } -} -``` - -#### pr_created - -```json -{ - "type": "pr_created", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "pr_number": 42, - "title": "Add user authentication", - "url": "https://github.com/user/repo/pull/42" - } -} -``` - -#### pr_merged - -```json -{ - "type": "pr_merged", - "repo_name": "my-repo", - "data": { - "pr_number": 42, - "title": "Add user authentication" - } -} -``` - -#### task_assigned - -```json -{ - "type": "task_assigned", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "task": "Add user authentication" - } -} -``` - -#### ci_failed - -```json -{ - "type": "ci_failed", - "repo_name": "my-repo", - "data": { - "pr_number": 42, - "job_name": "test-suite" - } -} -``` - -#### worker_stuck - -```json -{ - "type": "worker_stuck", - "repo_name": "my-repo", - "agent_name": "clever-fox", - "data": { - "duration_minutes": 30 - } -} -``` - -#### message_sent - -```json -{ - "type": "message_sent", - "repo_name": "my-repo", - "data": { - "from": "supervisor", - "to": "clever-fox", - "message_type": "task_assignment", - "body": "Please review the auth implementation" - } -} -``` - -## Hook Configuration - -### Available Hooks - -| Hook Name | Fires On | Priority | -|-----------|----------|----------| -| `on_event` | **All events** (catch-all) | Lower | -| `on_agent_started` | `agent_started` events | Higher | -| `on_agent_stopped` | `agent_stopped` events | Higher | -| `on_agent_idle` | `agent_idle` events | Higher | -| `on_pr_created` | `pr_created` events | Higher | -| `on_pr_merged` | `pr_merged` events | Higher | -| `on_task_assigned` | `task_assigned` events | Higher | -| `on_ci_failed` | `ci_failed` events | Higher | -| `on_worker_stuck` | `worker_stuck` events | Higher | -| `on_message_sent` | `message_sent` events | Higher | - -**Priority**: If both `on_event` and a specific hook (e.g., `on_pr_created`) are configured, **both** will fire. Specific hooks run **in addition to**, not instead of, the catch-all. - -### Setting Hooks - -```bash -# Set catch-all hook (gets all events) -multiclaude hooks set on_event /path/to/notify-all.sh - -# Set specific event hooks -multiclaude hooks set on_pr_created /path/to/notify-pr.sh -multiclaude hooks set on_ci_failed /path/to/alert-ci.sh - -# View current configuration -multiclaude hooks list - -# Clear a specific hook -multiclaude hooks clear on_pr_created - -# Clear all hooks -multiclaude hooks clear-all -``` - -### Hook Script Requirements - -1. **Executable**: `chmod +x /path/to/hook.sh` -2. **Read from stdin**: Event JSON is passed via stdin -3. **Exit quickly**: Hooks timeout after 30 seconds -4. **Handle errors**: multiclaude doesn't retry or log hook failures -5. **Be idempotent**: Same event may fire multiple times (rare, but possible) - -## Writing Hook Scripts - -### Template (Bash) - -```bash -#!/usr/bin/env bash -set -euo pipefail - -# Read event JSON from stdin -EVENT_JSON=$(cat) - -# Parse fields -EVENT_TYPE=$(echo "$EVENT_JSON" | jq -r '.type') -REPO_NAME=$(echo "$EVENT_JSON" | jq -r '.repo_name // "unknown"') -AGENT_NAME=$(echo "$EVENT_JSON" | jq -r '.agent_name // "unknown"') - -# Handle specific events -case "$EVENT_TYPE" in - pr_created) - PR_NUMBER=$(echo "$EVENT_JSON" | jq -r '.data.pr_number') - PR_URL=$(echo "$EVENT_JSON" | jq -r '.data.url') - TITLE=$(echo "$EVENT_JSON" | jq -r '.data.title') - - # Your notification logic - echo "PR #$PR_NUMBER created: $TITLE" - echo "URL: $PR_URL" - ;; - - ci_failed) - PR_NUMBER=$(echo "$EVENT_JSON" | jq -r '.data.pr_number') - JOB_NAME=$(echo "$EVENT_JSON" | jq -r '.data.job_name') - - # Send alert - echo "CI failed: $JOB_NAME on PR #$PR_NUMBER in $REPO_NAME" - ;; - - *) - # Unhandled event type - ;; -esac - -exit 0 -``` - -### Template (Python) - -```python -#!/usr/bin/env python3 -import json -import sys - -def main(): - # Read event from stdin - event = json.load(sys.stdin) - - event_type = event['type'] - repo_name = event.get('repo_name', 'unknown') - agent_name = event.get('agent_name', 'unknown') - data = event.get('data', {}) - - # Handle specific events - if event_type == 'pr_created': - pr_number = data['pr_number'] - title = data['title'] - url = data['url'] - print(f"PR #{pr_number} created: {title}") - print(f"URL: {url}") - - elif event_type == 'ci_failed': - pr_number = data['pr_number'] - job_name = data['job_name'] - print(f"CI failed: {job_name} on PR #{pr_number} in {repo_name}") - - else: - # Unhandled event - pass - -if __name__ == '__main__': - main() -``` - -### Template (Node.js) - -```javascript -#!/usr/bin/env node - -const readline = require('readline'); - -// Read event from stdin -let input = ''; -const rl = readline.createInterface({ input: process.stdin }); - -rl.on('line', (line) => { input += line; }); - -rl.on('close', () => { - const event = JSON.parse(input); - - const { type, repo_name, agent_name, data } = event; - - switch (type) { - case 'pr_created': - console.log(`PR #${data.pr_number} created: ${data.title}`); - console.log(`URL: ${data.url}`); - break; - - case 'ci_failed': - console.log(`CI failed: ${data.job_name} on PR #${data.pr_number}`); - break; - - default: - // Unhandled event - break; - } -}); -``` - -## Notification Examples - -### Slack Notification - -```bash -#!/usr/bin/env bash -# slack-notify.sh - Send multiclaude events to Slack - -set -euo pipefail - -# Configuration (set via environment or config file) -SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" - -if [ -z "$SLACK_WEBHOOK_URL" ]; then - echo "Error: SLACK_WEBHOOK_URL not set" >&2 - exit 1 -fi - -# Read event -EVENT_JSON=$(cat) -EVENT_TYPE=$(echo "$EVENT_JSON" | jq -r '.type') - -# Build Slack message based on event type -case "$EVENT_TYPE" in - pr_created) - REPO=$(echo "$EVENT_JSON" | jq -r '.repo_name') - AGENT=$(echo "$EVENT_JSON" | jq -r '.agent_name') - PR_NUMBER=$(echo "$EVENT_JSON" | jq -r '.data.pr_number') - TITLE=$(echo "$EVENT_JSON" | jq -r '.data.title') - URL=$(echo "$EVENT_JSON" | jq -r '.data.url') - - MESSAGE=":pr: *New PR in $REPO*\n<$URL|#$PR_NUMBER: $TITLE>\nCreated by: $AGENT" - ;; - - ci_failed) - REPO=$(echo "$EVENT_JSON" | jq -r '.repo_name') - PR_NUMBER=$(echo "$EVENT_JSON" | jq -r '.data.pr_number') - JOB_NAME=$(echo "$EVENT_JSON" | jq -r '.data.job_name') - - MESSAGE=":x: *CI Failed in $REPO*\nPR: #$PR_NUMBER\nJob: $JOB_NAME" - ;; - - pr_merged) - REPO=$(echo "$EVENT_JSON" | jq -r '.repo_name') - PR_NUMBER=$(echo "$EVENT_JSON" | jq -r '.data.pr_number') - TITLE=$(echo "$EVENT_JSON" | jq -r '.data.title') - - MESSAGE=":tada: *PR Merged in $REPO*\n#$PR_NUMBER: $TITLE" - ;; - - *) - # Skip other events or handle generically - exit 0 - ;; -esac - -# Send to Slack -curl -X POST "$SLACK_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"text\": \"$MESSAGE\"}" \ - --silent --show-error - -exit 0 -``` - -**Setup:** -```bash -# Get webhook URL from Slack: https://api.slack.com/messaging/webhooks -export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL" - -# Configure multiclaude -multiclaude hooks set on_event /usr/local/bin/slack-notify.sh -``` - -### Discord Webhook - -```python -#!/usr/bin/env python3 -# discord-notify.py - Send multiclaude events to Discord - -import json -import sys -import os -import requests - -DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL') - -if not DISCORD_WEBHOOK_URL: - print("Error: DISCORD_WEBHOOK_URL not set", file=sys.stderr) - sys.exit(1) - -# Read event -event = json.load(sys.stdin) -event_type = event['type'] - -# Build Discord message -if event_type == 'pr_created': - repo = event['repo_name'] - agent = event['agent_name'] - pr_number = event['data']['pr_number'] - title = event['data']['title'] - url = event['data']['url'] - - message = { - "content": f"🔔 **New PR in {repo}**", - "embeds": [{ - "title": f"#{pr_number}: {title}", - "url": url, - "color": 0x00FF00, - "fields": [ - {"name": "Created by", "value": agent, "inline": True} - ] - }] - } - -elif event_type == 'ci_failed': - repo = event['repo_name'] - pr_number = event['data']['pr_number'] - job_name = event['data']['job_name'] - - message = { - "content": f"❌ **CI Failed in {repo}**", - "embeds": [{ - "title": f"PR #{pr_number}", - "color": 0xFF0000, - "fields": [ - {"name": "Job", "value": job_name, "inline": True} - ] - }] - } - -else: - # Skip other events - sys.exit(0) - -# Send to Discord -requests.post(DISCORD_WEBHOOK_URL, json=message) -``` - -### Email Notification - -```bash -#!/usr/bin/env bash -# email-notify.sh - Send multiclaude events via email - -set -euo pipefail - -EMAIL_TO="${EMAIL_TO:-admin@example.com}" - -EVENT_JSON=$(cat) -EVENT_TYPE=$(echo "$EVENT_JSON" | jq -r '.type') - -# Only email critical events -case "$EVENT_TYPE" in - ci_failed|agent_failed|worker_stuck) - SUBJECT="[multiclaude] $EVENT_TYPE" - - # Format event as readable text - BODY=$(echo "$EVENT_JSON" | jq -r .) - - # Send email (requires mail/sendmail configured) - echo "$BODY" | mail -s "$SUBJECT" "$EMAIL_TO" - ;; - - *) - # Skip non-critical events - exit 0 - ;; -esac -``` - -### PagerDuty Alert - -```python -#!/usr/bin/env python3 -# pagerduty-alert.py - Create PagerDuty incidents for critical events - -import json -import sys -import os -import requests - -PAGERDUTY_API_KEY = os.environ.get('PAGERDUTY_API_KEY') -PAGERDUTY_SERVICE_ID = os.environ.get('PAGERDUTY_SERVICE_ID') - -if not PAGERDUTY_API_KEY or not PAGERDUTY_SERVICE_ID: - print("Error: PagerDuty credentials not set", file=sys.stderr) - sys.exit(1) - -event = json.load(sys.stdin) -event_type = event['type'] - -# Only alert on critical events -if event_type not in ['ci_failed', 'agent_failed', 'worker_stuck']: - sys.exit(0) - -# Build incident -incident = { - "incident": { - "type": "incident", - "title": f"multiclaude: {event_type} in {event.get('repo_name', 'unknown')}", - "service": { - "id": PAGERDUTY_SERVICE_ID, - "type": "service_reference" - }, - "body": { - "type": "incident_body", - "details": json.dumps(event, indent=2) - } - } -} - -# Create incident -requests.post( - 'https://api.pagerduty.com/incidents', - headers={ - 'Authorization': f'Token token={PAGERDUTY_API_KEY}', - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.pagerduty+json;version=2' - }, - json=incident -) -``` - -### Custom Webhook - -```javascript -#!/usr/bin/env node -// webhook-notify.js - Send events to custom webhook endpoint - -const https = require('https'); -const readline = require('readline'); - -const WEBHOOK_URL = process.env.WEBHOOK_URL || ''; -const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''; - -if (!WEBHOOK_URL) { - console.error('Error: WEBHOOK_URL not set'); - process.exit(1); -} - -// Read event from stdin -let input = ''; -const rl = readline.createInterface({ input: process.stdin }); - -rl.on('line', (line) => { input += line; }); - -rl.on('close', () => { - const event = JSON.parse(input); - - const url = new URL(WEBHOOK_URL); - - const data = JSON.stringify(event); - - const options = { - hostname: url.hostname, - port: url.port || 443, - path: url.pathname, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': data.length, - 'X-Webhook-Secret': WEBHOOK_SECRET - } - }; - - const req = https.request(options, (res) => { - // Don't care about response for fire-and-forget - }); - - req.on('error', (error) => { - console.error('Webhook error:', error); - }); - - req.write(data); - req.end(); -}); -``` - -## Advanced Patterns - -### Filtering Events - -```bash -#!/usr/bin/env bash -# filtered-notify.sh - Only notify on specific conditions - -EVENT_JSON=$(cat) -EVENT_TYPE=$(echo "$EVENT_JSON" | jq -r '.type') -REPO_NAME=$(echo "$EVENT_JSON" | jq -r '.repo_name') - -# Only notify for production repo -if [ "$REPO_NAME" != "production" ]; then - exit 0 -fi - -# Only notify for PR events -case "$EVENT_TYPE" in - pr_created|pr_merged|ci_failed) - # Send notification... - ;; - *) - exit 0 - ;; -esac -``` - -### Rate Limiting - -```python -#!/usr/bin/env python3 -# rate-limited-notify.py - Prevent notification spam - -import json -import sys -import time -import os - -STATE_FILE = '/tmp/multiclaude-notify-state.json' -RATE_LIMIT_SECONDS = 300 # Max 1 notification per 5 minutes - -# Load rate limit state -if os.path.exists(STATE_FILE): - with open(STATE_FILE) as f: - state = json.load(f) -else: - state = {} - -event = json.load(sys.stdin) -event_type = event['type'] - -# Check rate limit -now = time.time() -last_sent = state.get(event_type, 0) - -if now - last_sent < RATE_LIMIT_SECONDS: - # Rate limited - sys.exit(0) - -# Send notification... -# (your notification logic here) - -# Update state -state[event_type] = now -with open(STATE_FILE, 'w') as f: - json.dump(state, f) -``` - -### Aggregating Events - -```python -#!/usr/bin/env python3 -# aggregate-notify.py - Batch events and send summary - -import json -import sys -import time -import os -from collections import defaultdict - -BUFFER_FILE = '/tmp/multiclaude-event-buffer.json' -FLUSH_INTERVAL = 600 # Flush every 10 minutes - -# Load buffer -if os.path.exists(BUFFER_FILE): - with open(BUFFER_FILE) as f: - buffer_data = json.load(f) - events = buffer_data.get('events', []) - last_flush = buffer_data.get('last_flush', 0) -else: - events = [] - last_flush = 0 - -# Add current event -event = json.load(sys.stdin) -events.append(event) - -# Check if we should flush -now = time.time() -if now - last_flush < FLUSH_INTERVAL: - # Just buffer, don't send yet - with open(BUFFER_FILE, 'w') as f: - json.dump({'events': events, 'last_flush': last_flush}, f) - sys.exit(0) - -# Flush: aggregate and send summary -summary = defaultdict(int) -for e in events: - summary[e['type']] += 1 - -# Send notification with summary... -# (your notification logic here) - -# Clear buffer -with open(BUFFER_FILE, 'w') as f: - json.dump({'events': [], 'last_flush': now}, f) -``` - -## Testing Hooks - -### Manual Testing - -```bash -# Test your hook with sample event -echo '{ - "type": "pr_created", - "timestamp": "2024-01-15T10:30:00Z", - "repo_name": "test-repo", - "agent_name": "test-agent", - "data": { - "pr_number": 123, - "title": "Test PR", - "url": "https://github.com/user/repo/pull/123" - } -}' | /path/to/your-hook.sh -``` - -### Automated Testing - -```bash -#!/usr/bin/env bash -# test-hook.sh - Test hook script - -set -e - -HOOK_SCRIPT="$1" - -if [ ! -x "$HOOK_SCRIPT" ]; then - echo "Error: Hook script not executable: $HOOK_SCRIPT" - exit 1 -fi - -# Test pr_created event -echo "Testing pr_created event..." -echo '{ - "type": "pr_created", - "repo_name": "test", - "agent_name": "test", - "data": {"pr_number": 1, "title": "Test", "url": "https://example.com"} -}' | timeout 5 "$HOOK_SCRIPT" - -# Test ci_failed event -echo "Testing ci_failed event..." -echo '{ - "type": "ci_failed", - "repo_name": "test", - "data": {"pr_number": 1, "job_name": "test"} -}' | timeout 5 "$HOOK_SCRIPT" - -echo "All tests passed!" -``` - -## Debugging Hooks - -### Hook Not Firing - -```bash -# Check hook configuration -multiclaude hooks list - -# Check daemon logs for hook execution -tail -f ~/.multiclaude/daemon.log | grep -i hook - -# Manually trigger an event (future feature) -# multiclaude debug emit-event pr_created --repo test --pr 123 -``` - -### Hook Errors - -Hook scripts run with stderr captured. To debug: - -```bash -# Add logging to your hook -echo "Hook started: $EVENT_TYPE" >> /tmp/hook-debug.log -echo "Event JSON: $EVENT_JSON" >> /tmp/hook-debug.log -``` - -### Hook Timeouts - -Hooks must complete within 30 seconds. If your hook does heavy work: - -```bash -#!/usr/bin/env bash -# Long-running work in background -( - # Your slow notification logic - sleep 10 - curl ... -) & - -# Hook exits immediately -exit 0 -``` - -## Security Considerations - -1. **Validate Webhook URLs**: Don't allow user input in webhook URLs -2. **Protect Secrets**: Use environment variables, not hardcoded credentials -3. **Sanitize Event Data**: Event data comes from multiclaude, but sanitize before shell execution -4. **Limit Permissions**: Run hooks with minimal necessary permissions -5. **Rate Limit**: Prevent notification spam with rate limiting - -Example (preventing injection): - -```bash -# Bad - vulnerable to injection -MESSAGE="PR created: $(echo $EVENT_JSON | jq -r .data.title)" - -# Good - use jq's raw output -MESSAGE="PR created: $(echo "$EVENT_JSON" | jq -r '.data.title')" -``` - -## Hook Performance - -- **Timeout**: 30 seconds hard limit -- **Concurrency**: Hooks run in parallel (daemon doesn't wait) -- **Memory**: Hook processes are independent, can use any amount -- **Retries**: None - hook failures are silent - -**Best Practices:** -- Keep hooks fast (<5 seconds) -- Do heavy work in background -- Use async notification APIs -- Log to file for debugging, not stdout - -## Related Documentation - -- **[`EXTENSIBILITY.md`](../EXTENSIBILITY.md)** - Overview of extension points -- **[`STATE_FILE_INTEGRATION.md`](STATE_FILE_INTEGRATION.md)** - For building monitoring tools -- **[`examples/hooks/`](../../examples/hooks/)** - Working hook examples -- `internal/events/events.go` - Event type definitions (canonical source) - -## Contributing Hook Examples - -Have a working hook integration? Contribute it! - -1. Add to `examples/hooks/-notify.sh` -2. Include setup instructions in comments -3. Test with `echo '...' | your-hook.sh` -4. PR to the repository - -Popular integrations we'd love to see: -- Microsoft Teams -- Telegram -- Matrix -- Custom monitoring systems -- Project management tools (Jira, Linear, etc.) +## How to add (future) +1. Implement event types and emitters in code. +2. Add a verified list of event names to this file (replace the marker above). +3. Update `cmd/verify-docs` to include the new events and payload schemas. diff --git a/docs/extending/SOCKET_API.md b/docs/extending/SOCKET_API.md index 1069727..601899e 100644 --- a/docs/extending/SOCKET_API.md +++ b/docs/extending/SOCKET_API.md @@ -1,1153 +1,103 @@ -# Socket API Reference - -> **NOTE: COMMAND VERIFICATION NEEDED** -> -> Not all commands documented here have been verified against the current codebase. -> Hook-related commands (`get_hook_config`, `update_hook_config`) are **not implemented** -> as the event hooks system does not exist. Other commands should be verified against -> `internal/daemon/daemon.go` before use. - -**Extension Point:** Programmatic control via Unix socket IPC - -This guide documents the socket API for building custom control tools, automation scripts, and alternative CLIs. The socket API provides programmatic access to multiclaude state and operations. - -## Overview - -The multiclaude daemon exposes a Unix socket (`~/.multiclaude/daemon.sock`) for IPC. External tools can: -- Query daemon status and state -- Add/remove repositories and agents -- Trigger operations (cleanup, message routing) -- Configure hooks and settings - -**vs. State File:** -- **State File**: Read-only monitoring -- **Socket API**: Full programmatic control - -**vs. CLI:** -- **CLI**: Human-friendly interface (wraps socket API) -- **Socket API**: Machine-friendly interface (structured JSON) - -## Socket Location - -```bash -# Default location -~/.multiclaude/daemon.sock - -# Find programmatically -multiclaude config --paths | jq -r .socket_path -``` +# Socket API (Current Implementation) + + + +The socket API is the only write-capable extension surface in multiclaude today. It is implemented in `internal/daemon/daemon.go` (`handleRequest`). This document tracks only the commands that exist in the code. Anything not listed here is **not implemented**. ## Protocol - -### Request Format - -```json -{ - "command": "status", - "args": { - "key": "value" - } -} -``` - -**Fields:** -- `command` (string, required): Command name (see Command Reference) -- `args` (object, optional): Command-specific arguments - -### Response Format - -```json -{ - "success": true, - "data": { /* command-specific data */ }, - "error": "" -} -``` - -**Fields:** -- `success` (boolean): Whether command succeeded -- `data` (any): Command response data (if successful) -- `error` (string): Error message (if failed) - -## Client Libraries +- Transport: Unix domain socket at `~/.multiclaude/daemon.sock` +- Request type: JSON object `{ "command": "", "args": { ... } }` +- Response type: `{ "success": true|false, "data": any, "error": string }` +- Client helper: `internal/socket.Client` + +## Command Reference (source of truth) +Each command below matches a `case` in `handleRequest`. + +| Command | Description | Args | +|---------|-------------|------| +| `ping` | Health check | none | +| `status` | Daemon status summary | none | +| `stop` | Stop the daemon | none | +| `list_repos` | List tracked repos (optionally rich info) | `rich` (bool, optional) | +| `add_repo` | Track a new repo | `path` (string) | +| `remove_repo` | Stop tracking a repo | `name` (string) | +| `add_agent` | Register an agent in state | `repo`, `name`, `type`, `worktree_path`, `tmux_window`, `session_id`, `pid` | +| `remove_agent` | Remove agent from state | `repo`, `name` | +| `list_agents` | List agents for a repo | `repo` | +| `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 | +| `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) | +| `set_current_repo` | Persist current repo selection | `repo` | +| `get_current_repo` | Read current repo selection | none | +| `clear_current_repo` | Clear current repo selection | none | +| `route_messages` | Force message routing cycle | none | +| `task_history` | Return task history for a repo | `repo` | +| `spawn_agent` | Create a new agent worktree | `repo`, `type`, `task`, `name` (optional) | + +## Minimal client examples ### Go - ```go package main import ( "fmt" + "github.com/dlorenc/multiclaude/internal/socket" ) func main() { - client := socket.NewClient("~/.multiclaude/daemon.sock") - - resp, err := client.Send(socket.Request{ - Command: "status", - }) - + client := socket.NewClient("/home/user/.multiclaude/daemon.sock") + resp, err := client.Send(socket.Request{Command: "ping"}) if err != nil { panic(err) } - - if !resp.Success { - panic(resp.Error) - } - - fmt.Printf("Status: %+v\n", resp.Data) + fmt.Printf("success=%v data=%v\n", resp.Success, resp.Data) } ``` ### Python - ```python -import socket import json -import os - -class MulticlaudeClient: - def __init__(self, sock_path="~/.multiclaude/daemon.sock"): - self.sock_path = os.path.expanduser(sock_path) - - def send(self, command, args=None): - # Connect to socket - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(self.sock_path) - - try: - # Send request - request = {"command": command} - if args: - request["args"] = args - - sock.sendall(json.dumps(request).encode() + b'\n') - - # Read response - data = b'' - while True: - chunk = sock.recv(4096) - if not chunk: - break - data += chunk - try: - response = json.loads(data.decode()) - break - except json.JSONDecodeError: - continue - - if not response['success']: - raise Exception(response['error']) - - return response['data'] - - finally: - sock.close() - -# Usage -client = MulticlaudeClient() -status = client.send("status") -print(f"Daemon running: {status['running']}") -``` - -### Bash - -```bash -#!/bin/bash -# multiclaude-api.sh - Socket API client in bash - -SOCK="$HOME/.multiclaude/daemon.sock" - -multiclaude_api() { - local command="$1" - shift - local args="$@" - - # Build request JSON - local request - if [ -n "$args" ]; then - request=$(jq -n --arg cmd "$command" --argjson args "$args" \ - '{command: $cmd, args: $args}') - else - request=$(jq -n --arg cmd "$command" '{command: $cmd}') - fi - - # Send to socket and parse response - echo "$request" | nc -U "$SOCK" | jq -r . -} - -# Usage -multiclaude_api "status" -multiclaude_api "list_repos" -``` - -### Node.js - -```javascript -const net = require('net'); -const os = require('os'); -const path = require('path'); - -class MulticlaudeClient { - constructor(sockPath = path.join(os.homedir(), '.multiclaude/daemon.sock')) { - this.sockPath = sockPath; - } - - async send(command, args = null) { - return new Promise((resolve, reject) => { - const client = net.createConnection(this.sockPath); - - // Build request - const request = { command }; - if (args) request.args = args; - - client.on('connect', () => { - client.write(JSON.stringify(request) + '\n'); - }); - - let data = ''; - client.on('data', (chunk) => { - data += chunk.toString(); - try { - const response = JSON.parse(data); - client.end(); - - if (!response.success) { - reject(new Error(response.error)); - } else { - resolve(response.data); - } - } catch (e) { - // Incomplete JSON, wait for more data - } - }); - - client.on('error', reject); - }); - } -} - -// Usage -(async () => { - const client = new MulticlaudeClient(); - const status = await client.send('status'); - console.log('Daemon status:', status); -})(); -``` - -## Command Reference - -### Daemon Management - -#### ping - -**Description:** Check if daemon is alive - -**Request:** -```json -{ - "command": "ping" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "pong" -} -``` - -#### status - -**Description:** Get daemon status - -**Request:** -```json -{ - "command": "status" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "running": true, - "pid": 12345, - "repos": 2, - "agents": 5, - "socket_path": "/home/user/.multiclaude/daemon.sock" - } -} -``` - -#### stop - -**Description:** Stop the daemon gracefully - -**Request:** -```json -{ - "command": "stop" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Daemon stopping" -} -``` - -**Note:** Daemon will stop asynchronously after responding. - -### Repository Management - -#### list_repos - -**Description:** List all tracked repositories - -**Request:** -```json -{ - "command": "list_repos" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "repos": ["my-app", "backend-api"] - } -} -``` - -#### add_repo - -**Description:** Add a new repository (equivalent to `multiclaude init`) - -**Request:** -```json -{ - "command": "add_repo", - "args": { - "name": "my-app", - "github_url": "https://github.com/user/my-app", - "merge_queue_enabled": true, - "merge_queue_track_mode": "all" - } -} -``` - -**Args:** -- `name` (string, required): Repository name -- `github_url` (string, required): GitHub URL -- `merge_queue_enabled` (boolean, optional): Enable merge queue (default: true) -- `merge_queue_track_mode` (string, optional): Track mode: "all", "author", "assigned" (default: "all") - -**Response:** -```json -{ - "success": true, - "data": "Repository 'my-app' initialized" -} -``` - -#### remove_repo - -**Description:** Remove a repository - -**Request:** -```json -{ - "command": "remove_repo", - "args": { - "name": "my-app" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Repository 'my-app' removed" -} -``` - -#### get_repo_config - -**Description:** Get repository configuration - -**Request:** -```json -{ - "command": "get_repo_config", - "args": { - "name": "my-app" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "merge_queue_enabled": true, - "merge_queue_track_mode": "all" - } -} -``` - -#### update_repo_config - -**Description:** Update repository configuration - -**Request:** -```json -{ - "command": "update_repo_config", - "args": { - "name": "my-app", - "merge_queue_enabled": false, - "merge_queue_track_mode": "author" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Repository configuration updated" -} -``` - -#### set_current_repo - -**Description:** Set the default repository - -**Request:** -```json -{ - "command": "set_current_repo", - "args": { - "name": "my-app" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Current repository set to 'my-app'" -} -``` - -#### get_current_repo - -**Description:** Get the default repository name - -**Request:** -```json -{ - "command": "get_current_repo" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "my-app" -} -``` - -#### clear_current_repo - -**Description:** Clear the default repository - -**Request:** -```json -{ - "command": "clear_current_repo" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Current repository cleared" -} -``` - -### Agent Management - -#### list_agents - -**Description:** List all agents for a repository - -**Request:** -```json -{ - "command": "list_agents", - "args": { - "repo": "my-app" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "agents": { - "supervisor": { - "type": "supervisor", - "pid": 12345, - "created_at": "2024-01-15T10:00:00Z" - }, - "clever-fox": { - "type": "worker", - "task": "Add authentication", - "pid": 12346, - "created_at": "2024-01-15T10:15:00Z" - } - } - } -} -``` - -#### add_agent - -**Description:** Add/spawn a new agent - -**Request:** -```json -{ - "command": "add_agent", - "args": { - "repo": "my-app", - "name": "clever-fox", - "type": "worker", - "task": "Add user authentication" - } -} -``` - -**Args:** -- `repo` (string, required): Repository name -- `name` (string, required): Agent name -- `type` (string, required): Agent type: "supervisor", "worker", "merge-queue", "workspace", "review" -- `task` (string, optional): Task description (for workers) - -**Response:** -```json -{ - "success": true, - "data": "Agent 'clever-fox' created" -} -``` - -#### remove_agent - -**Description:** Remove/kill an agent - -**Request:** -```json -{ - "command": "remove_agent", - "args": { - "repo": "my-app", - "name": "clever-fox" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Agent 'clever-fox' removed" -} -``` - -#### complete_agent - -**Description:** Mark a worker as completed (called by workers themselves) - -**Request:** -```json -{ - "command": "complete_agent", - "args": { - "repo": "my-app", - "name": "clever-fox", - "summary": "Added JWT authentication with refresh tokens", - "failure_reason": "" - } -} -``` - -**Args:** -- `repo` (string, required): Repository name -- `name` (string, required): Agent name -- `summary` (string, optional): Completion summary -- `failure_reason` (string, optional): Failure reason (if task failed) - -**Response:** -```json -{ - "success": true, - "data": "Agent marked for cleanup" -} -``` - -#### restart_agent - -**Description:** Restart a crashed or stopped agent - -**Request:** -```json -{ - "command": "restart_agent", - "args": { - "repo": "my-app", - "name": "supervisor" - } -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Agent 'supervisor' restarted" -} -``` - -### Task History - -#### task_history - -**Description:** Get task history for a repository - -**Request:** -```json -{ - "command": "task_history", - "args": { - "repo": "my-app", - "limit": 10 - } -} -``` - -**Args:** -- `repo` (string, required): Repository name -- `limit` (integer, optional): Max entries to return (0 = all) - -**Response:** -```json -{ - "success": true, - "data": { - "history": [ - { - "name": "brave-lion", - "task": "Fix login bug", - "status": "merged", - "pr_url": "https://github.com/user/my-app/pull/42", - "pr_number": 42, - "created_at": "2024-01-14T10:00:00Z", - "completed_at": "2024-01-14T11:00:00Z" - } - ] - } -} -``` - -### Hook Configuration - -#### get_hook_config - -**Description:** Get current hook configuration - -**Request:** -```json -{ - "command": "get_hook_config" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "on_event": "", - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "", - "on_agent_idle": "", - "on_agent_started": "", - "on_agent_stopped": "", - "on_task_assigned": "", - "on_worker_stuck": "", - "on_message_sent": "" - } -} -``` - -#### update_hook_config - -**Description:** Update hook configuration - -**Request:** -```json -{ - "command": "update_hook_config", - "args": { - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "/usr/local/bin/alert.sh" - } -} -``` - -**Args:** Any hook configuration fields (see [`EVENT_HOOKS.md`](EVENT_HOOKS.md)) - -**Response:** -```json -{ - "success": true, - "data": "Hook configuration updated" -} -``` - -### Maintenance - -#### trigger_cleanup - -**Description:** Trigger immediate cleanup of dead agents - -**Request:** -```json -{ - "command": "trigger_cleanup" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Cleanup triggered" -} -``` - -#### repair_state - -**Description:** Repair inconsistent state (equivalent to `multiclaude repair`) - -**Request:** -```json -{ - "command": "repair_state" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "State repaired" -} -``` - -#### route_messages - -**Description:** Trigger immediate message routing (normally runs every 2 minutes) - -**Request:** -```json -{ - "command": "route_messages" -} -``` - -**Response:** -```json -{ - "success": true, - "data": "Message routing triggered" -} -``` - -## Error Handling - -### Connection Errors - -```python -try: - response = client.send("status") -except FileNotFoundError: - print("Error: Daemon not running") - print("Start with: multiclaude start") -except PermissionError: - print("Error: Socket permission denied") -``` - -### Command Errors - -```python -response = client.send("add_repo", {"name": "test"}) # Missing github_url - -# Response: -# { -# "success": false, -# "error": "missing required argument: github_url" -# } -``` - -### Unknown Commands - -```python -response = client.send("invalid_command") - -# Response: -# { -# "success": false, -# "error": "unknown command: \"invalid_command\"" -# } -``` - -## Common Patterns - -### Check If Daemon Is Running - -```python -def is_daemon_running(): - try: - client = MulticlaudeClient() - client.send("ping") - return True - except: - return False -``` - -### Spawn Worker - -```python -def spawn_worker(repo, task): - client = MulticlaudeClient() - - # Generate random worker name (you could use internal/names package) - import random - adjectives = ["clever", "brave", "swift", "keen"] - animals = ["fox", "lion", "eagle", "wolf"] - name = f"{random.choice(adjectives)}-{random.choice(animals)}" - - client.send("add_agent", { - "repo": repo, - "name": name, - "type": "worker", - "task": task - }) - - return name -``` - -### Wait for Worker Completion - -```python -import time - -def wait_for_completion(repo, worker_name, timeout=3600): - client = MulticlaudeClient() - start = time.time() - - while time.time() - start < timeout: - # Check if worker still exists - agents = client.send("list_agents", {"repo": repo})['agents'] - - if worker_name not in agents: - # Worker completed - return True - - agent = agents[worker_name] - if agent.get('ready_for_cleanup'): - return True - - time.sleep(30) # Poll every 30 seconds - - return False -``` - -### Get Active Workers - -```python -def get_active_workers(repo): - client = MulticlaudeClient() - agents = client.send("list_agents", {"repo": repo})['agents'] - - return [ - { - 'name': name, - 'task': agent['task'], - 'created': agent['created_at'] - } - for name, agent in agents.items() - if agent['type'] == 'worker' and agent.get('pid', 0) > 0 - ] -``` - -## Building a Custom CLI - -```python -#!/usr/bin/env python3 -# myclaude - Custom CLI wrapping socket API - -import sys -from multiclaude_client import MulticlaudeClient - -def main(): - if len(sys.argv) < 2: - print("Usage: myclaude [args...]") - sys.exit(1) - - command = sys.argv[1] - client = MulticlaudeClient() - - try: - if command == "status": - status = client.send("status") - print(f"Daemon PID: {status['pid']}") - print(f"Repos: {status['repos']}") - print(f"Agents: {status['agents']}") - - elif command == "spawn": - if len(sys.argv) < 4: - print("Usage: myclaude spawn ") - sys.exit(1) - - repo = sys.argv[2] - task = ' '.join(sys.argv[3:]) - name = spawn_worker(repo, task) - print(f"Spawned worker: {name}") - - elif command == "workers": - repo = sys.argv[2] if len(sys.argv) > 2 else None - if not repo: - print("Usage: myclaude workers ") - sys.exit(1) - - workers = get_active_workers(repo) - for w in workers: - print(f"{w['name']}: {w['task']}") - - else: - print(f"Unknown command: {command}") - sys.exit(1) - - except Exception as e: - print(f"Error: {e}") - sys.exit(1) - -if __name__ == '__main__': - main() -``` - -## Integration Examples - -### CI/CD Pipeline - -```yaml -# .github/workflows/multiclaude.yml -name: Multiclaude Task - -on: [push] - -jobs: - spawn-task: - runs-on: self-hosted # Requires multiclaude on runner - steps: - - name: Spawn multiclaude worker - run: | - python3 < { - const status = await client.send('status'); - res.json(status); -}); - -app.get('/api/repos', async (req, res) => { - const data = await client.send('list_repos'); - res.json(data.repos); -}); - -app.post('/api/spawn', async (req, res) => { - const { repo, task } = req.body; - await client.send('add_agent', { - repo, - name: generateName(), - type: 'worker', - task - }); - res.json({ success: true }); -}); - -app.listen(3000); -``` - -## Performance - -- **Latency**: <1ms for simple commands (ping, status) -- **Throughput**: Hundreds of requests/second -- **Concurrency**: Daemon handles requests in parallel via goroutines -- **Blocking**: Long-running operations return immediately (async execution) - -## Security - -### Socket Permissions - -```bash -# Socket is user-only by default -ls -l ~/.multiclaude/daemon.sock -# srw------- 1 user user 0 ... daemon.sock -``` - -**Recommendation:** Don't change socket permissions. Only the owning user should access. - -### Input Validation - -The daemon validates all inputs: -- Repository names: alphanumeric + hyphens -- Agent names: alphanumeric + hyphens -- File paths: checked for existence -- URLs: basic validation - -**Client-side:** Still validate inputs before sending to prevent API errors. - -### Command Injection - -Daemon never executes shell commands with user input. Safe patterns: -- Agent names → tmux window names (sanitized) -- Tasks → embedded in prompts (not executed) -- URLs → passed to `git clone` (validated) - -## Troubleshooting - -### Socket Not Found - -```bash -# Check if daemon is running -ps aux | grep multiclaude - -# If not running -multiclaude start -``` - -### Permission Denied - -```bash -# Check socket permissions -ls -l ~/.multiclaude/daemon.sock - -# Ensure you're the same user that started daemon -whoami -ps aux | grep multiclaude | grep -v grep -``` - -### Stale Socket +import socket -```bash -# Socket exists but daemon not running -multiclaude repair +sock_path = "/home/user/.multiclaude/daemon.sock" +req = {"command": "status", "args": {}} -# Or manually remove and restart -rm ~/.multiclaude/daemon.sock -multiclaude start +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(sock_path) + s.sendall(json.dumps(req).encode("utf-8")) + raw = s.recv(8192) + resp = json.loads(raw.decode("utf-8")) + print(resp) ``` -### Timeout - -Long commands (add_repo with clone) may take time. Set longer timeout: - -```python -# Python -sock.settimeout(60) # 60 second timeout - -# Node.js -client.setTimeout(60000); -``` - -## Related Documentation - -- **[`EXTENSIBILITY.md`](../EXTENSIBILITY.md)** - Overview of extension points -- **[`STATE_FILE_INTEGRATION.md`](STATE_FILE_INTEGRATION.md)** - For read-only monitoring -- `internal/socket/socket.go` - Socket implementation -- `internal/daemon/daemon.go` - Request handlers (lines 607-685) - -## Contributing - -When adding new socket commands: - -1. Add command to `handleRequest()` in `internal/daemon/daemon.go` -2. Implement handler function (e.g., `handleMyCommand()`) -3. Update this document with command reference -4. Add tests in `internal/daemon/daemon_test.go` -5. Update CLI wrapper in `internal/cli/cli.go` if applicable +## Updating this doc +- Add/remove commands **only** when the `handleRequest` switch changes. +- Keep the `socket-commands` marker above in sync; `go run ./cmd/verify-docs` enforces alignment. +- If you add arguments, update the table here with the real fields used by the handler. diff --git a/docs/extending/STATE_FILE_INTEGRATION.md b/docs/extending/STATE_FILE_INTEGRATION.md index e3b922f..cf41b63 100644 --- a/docs/extending/STATE_FILE_INTEGRATION.md +++ b/docs/extending/STATE_FILE_INTEGRATION.md @@ -1,762 +1,116 @@ -# State File Integration Guide +# State File Integration (Read-Only) -**Extension Point:** Read-only monitoring via `~/.multiclaude/state.json` + + + + + + + -This guide documents the complete state file schema and patterns for building external tools that read multiclaude state. This is the **simplest and safest** extension point - no daemon interaction required, zero risk of breaking multiclaude operation. - -## Overview - -The state file (`~/.multiclaude/state.json`) is the single source of truth for: -- All tracked repositories -- All active agents (supervisor, merge-queue, workers, reviews) -- Task history and PR status -- Hook configuration -- Merge queue settings - -**Key Characteristics:** -- **Atomic Writes**: Daemon writes to temp file, then atomic rename (never corrupt) -- **Read-Only for Extensions**: Never modify directly - use socket API instead -- **JSON Format**: Standard, easy to parse in any language -- **Always Available**: Persists across daemon restarts - -## File Location - -```bash -# Default location -~/.multiclaude/state.json - -# Find it programmatically -state_path="$HOME/.multiclaude/state.json" - -# Or use multiclaude config -multiclaude_dir=$(multiclaude config --paths | jq -r .state_file) -``` - -## Complete Schema Reference - -### Root Structure +The daemon persists state to `~/.multiclaude/state.json` and writes it atomically. This file is safe for external tools to **read only**. Write access belongs to the daemon. +## Schema (from `internal/state/state.go`) ```json { "repos": { - "": { /* Repository object */ } - }, - "current_repo": "my-repo", // Optional: default repository - "hooks": { /* HookConfig object */ } -} -``` - -### Repository Object - -```json -{ - "github_url": "https://github.com/user/repo", - "tmux_session": "mc-my-repo", - "agents": { - "": { /* Agent object */ } - }, - "task_history": [ /* TaskHistoryEntry objects */ ], - "merge_queue_config": { /* MergeQueueConfig object */ } -} -``` - -### Agent Object - -```json -{ - "type": "worker", // "supervisor" | "worker" | "merge-queue" | "workspace" | "review" - "worktree_path": "/path/to/worktree", - "tmux_window": "0", // Window index in tmux session - "session_id": "claude-session-id", - "pid": 12345, // Process ID (0 if not running) - "task": "Implement feature X", // Only for workers - "summary": "Added auth module", // Only for workers (completion summary) - "failure_reason": "Tests failed", // Only for workers (if task failed) - "created_at": "2024-01-15T10:30:00Z", - "last_nudge": "2024-01-15T10:35:00Z", - "ready_for_cleanup": false // Only for workers (signals completion) -} -``` - -**Agent Types:** -- `supervisor`: Main orchestrator for the repository -- `merge-queue`: Monitors and merges approved PRs -- `worker`: Executes specific tasks -- `workspace`: Interactive workspace agent -- `review`: Reviews a specific PR - -### TaskHistoryEntry Object - -```json -{ - "name": "clever-fox", // Worker name - "task": "Add user authentication", // Task description - "branch": "multiclaude/clever-fox", // Git branch - "pr_url": "https://github.com/user/repo/pull/42", - "pr_number": 42, - "status": "merged", // See status values below - "summary": "Implemented JWT-based auth with refresh tokens", - "failure_reason": "", // Populated if status is "failed" - "created_at": "2024-01-15T10:00:00Z", - "completed_at": "2024-01-15T11:30:00Z" -} -``` - -**Status Values:** -- `open`: PR created, not yet merged or closed -- `merged`: PR was merged successfully -- `closed`: PR was closed without merging -- `no-pr`: Task completed but no PR was created -- `failed`: Task failed (see `failure_reason`) -- `unknown`: Status couldn't be determined - -### MergeQueueConfig Object - -```json -{ - "enabled": true, // Whether merge-queue agent runs - "track_mode": "all" // "all" | "author" | "assigned" -} -``` - -**Track Modes:** -- `all`: Monitor all PRs in the repository -- `author`: Only PRs where multiclaude user is the author -- `assigned`: Only PRs where multiclaude user is assigned - -### HookConfig Object - -```json -{ - "on_event": "/usr/local/bin/notify.sh", // Catch-all hook - "on_pr_created": "/usr/local/bin/slack-pr.sh", - "on_agent_idle": "", - "on_merge_complete": "", - "on_agent_started": "", - "on_agent_stopped": "", - "on_task_assigned": "", - "on_ci_failed": "/usr/local/bin/alert-ci.sh", - "on_worker_stuck": "", - "on_message_sent": "" -} -``` - -## Example State File - -```json -{ - "repos": { - "my-app": { - "github_url": "https://github.com/user/my-app", - "tmux_session": "mc-my-app", + "": { + "github_url": "https://github.com/owner/repo", + "tmux_session": "mc-repo", "agents": { - "supervisor": { - "type": "supervisor", - "worktree_path": "/home/user/.multiclaude/wts/my-app/supervisor", - "tmux_window": "0", - "session_id": "claude-abc123", + "": { + "type": "supervisor|worker|merge-queue|pr-shepherd|workspace|review|generic-persistent", + "worktree_path": "/path/to/worktree", + "tmux_window": "window-name", + "session_id": "uuid", "pid": 12345, - "created_at": "2024-01-15T10:00:00Z", - "last_nudge": "2024-01-15T10:30:00Z" - }, - "merge-queue": { - "type": "merge-queue", - "worktree_path": "/home/user/.multiclaude/wts/my-app/merge-queue", - "tmux_window": "1", - "session_id": "claude-def456", - "pid": 12346, - "created_at": "2024-01-15T10:00:00Z", - "last_nudge": "2024-01-15T10:30:00Z" - }, - "clever-fox": { - "type": "worker", - "worktree_path": "/home/user/.multiclaude/wts/my-app/clever-fox", - "tmux_window": "2", - "session_id": "claude-ghi789", - "pid": 12347, - "task": "Add user authentication", - "summary": "", - "failure_reason": "", - "created_at": "2024-01-15T10:15:00Z", - "last_nudge": "2024-01-15T10:30:00Z", + "task": "task description", + "summary": "optional summary", + "failure_reason": "optional failure", + "created_at": "2025-01-01T00:00:00Z", + "last_nudge": "2025-01-01T00:00:00Z", "ready_for_cleanup": false } }, "task_history": [ { - "name": "brave-lion", - "task": "Fix login bug", - "branch": "multiclaude/brave-lion", - "pr_url": "https://github.com/user/my-app/pull/41", - "pr_number": 41, - "status": "merged", - "summary": "Fixed race condition in session validation", - "failure_reason": "", - "created_at": "2024-01-14T15:00:00Z", - "completed_at": "2024-01-14T16:30:00Z" + "name": "clever-fox", + "task": "...", + "branch": "work/clever-fox", + "pr_url": "https://github.com/...", + "pr_number": 42, + "status": "open|merged|closed|no-pr|failed|unknown", + "summary": "optional summary", + "failure_reason": "optional failure", + "created_at": "2025-01-01T00:00:00Z", + "completed_at": "2025-01-02T00:00:00Z" } ], "merge_queue_config": { "enabled": true, - "track_mode": "all" - } + "track_mode": "all|author|assigned" + }, + "pr_shepherd_config": { + "enabled": true, + "track_mode": "all|author|assigned" + }, + "fork_config": { + "is_fork": true, + "upstream_url": "https://github.com/upstream/repo", + "upstream_owner": "upstream", + "upstream_repo": "repo", + "force_fork_mode": false + }, + "target_branch": "main" } }, - "current_repo": "my-app", - "hooks": { - "on_event": "", - "on_pr_created": "/usr/local/bin/notify-slack.sh", - "on_ci_failed": "/usr/local/bin/alert-pagerduty.sh" - } + "current_repo": "" } ``` -## Reading the State File - -### Basic Read (Any Language) - -```bash -# Bash -state=$(cat ~/.multiclaude/state.json) -repo_count=$(echo "$state" | jq '.repos | length') - -# Python -import json -with open(os.path.expanduser('~/.multiclaude/state.json')) as f: - state = json.load(f) - -# Node.js -const state = JSON.parse(fs.readFileSync( - path.join(os.homedir(), '.multiclaude/state.json'), - 'utf8' -)); - -# Go -data, _ := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json")) -var state State -json.Unmarshal(data, &state) -``` - -### Watching for Changes - -The state file is updated frequently (every agent action, every status change). Use file watching instead of polling. - -#### Go (fsnotify) +## Reading the state file +### Go ```go package main import ( "encoding/json" - "log" + "fmt" "os" - "github.com/fsnotify/fsnotify" "github.com/dlorenc/multiclaude/internal/state" ) func main() { - watcher, _ := fsnotify.NewWatcher() - defer watcher.Close() - - statePath := os.ExpandEnv("$HOME/.multiclaude/state.json") - watcher.Add(statePath) - - for { - select { - case event := <-watcher.Events: - if event.Op&fsnotify.Write == fsnotify.Write { - // Re-read state - data, _ := os.ReadFile(statePath) - var s state.State - json.Unmarshal(data, &s) - - // Do something with updated state - processState(&s) - } - case err := <-watcher.Errors: - log.Println("Error:", err) - } - } -} -``` - -#### Python (watchdog) - -```python -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler -import json -import os - -class StateFileHandler(FileSystemEventHandler): - def on_modified(self, event): - if event.src_path.endswith('state.json'): - with open(event.src_path) as f: - state = json.load(f) - process_state(state) - -observer = Observer() -observer.schedule( - StateFileHandler(), - os.path.expanduser('~/.multiclaude'), - recursive=False -) -observer.start() -``` - -#### Node.js (chokidar) - -```javascript -const chokidar = require('chokidar'); -const fs = require('fs'); -const path = require('path'); - -const statePath = path.join(os.homedir(), '.multiclaude/state.json'); - -chokidar.watch(statePath).on('change', (path) => { - const state = JSON.parse(fs.readFileSync(path, 'utf8')); - processState(state); -}); -``` - -## Common Queries - -### Get All Active Workers - -```javascript -// JavaScript -const workers = Object.entries(state.repos) - .flatMap(([repoName, repo]) => - Object.entries(repo.agents) - .filter(([_, agent]) => agent.type === 'worker' && agent.pid > 0) - .map(([name, agent]) => ({ - repo: repoName, - name: name, - task: agent.task, - created: new Date(agent.created_at) - })) - ); -``` - -```python -# Python -workers = [ - { - 'repo': repo_name, - 'name': agent_name, - 'task': agent['task'], - 'created': agent['created_at'] - } - for repo_name, repo in state['repos'].items() - for agent_name, agent in repo['agents'].items() - if agent['type'] == 'worker' and agent.get('pid', 0) > 0 -] -``` - -```bash -# Bash/jq -workers=$(cat ~/.multiclaude/state.json | jq -r ' - .repos | to_entries[] | - .value.agents | to_entries[] | - select(.value.type == "worker" and .value.pid > 0) | - {repo: .key, name: .key, task: .value.task} -') -``` - -### Get Recent Task History - -```python -# Python - Get last 10 completed tasks across all repos -from datetime import datetime - -tasks = [] -for repo_name, repo in state['repos'].items(): - for entry in repo.get('task_history', []): - tasks.append({ - 'repo': repo_name, - **entry - }) - -# Sort by completion time, most recent first -tasks.sort(key=lambda x: x.get('completed_at', ''), reverse=True) -recent_tasks = tasks[:10] -``` - -### Calculate Success Rate - -```javascript -// JavaScript -function calculateSuccessRate(state, repoName) { - const history = state.repos[repoName]?.task_history || []; - const total = history.length; - const merged = history.filter(t => t.status === 'merged').length; - return total > 0 ? (merged / total * 100).toFixed(1) : 0; -} -``` - -### Find Stuck Workers - -```python -# Python - Find workers idle for > 30 minutes -from datetime import datetime, timedelta - -now = datetime.utcnow() -stuck_threshold = timedelta(minutes=30) - -stuck_workers = [] -for repo_name, repo in state['repos'].items(): - for agent_name, agent in repo['agents'].items(): - if agent['type'] != 'worker' or agent.get('pid', 0) == 0: - continue - - last_nudge = datetime.fromisoformat( - agent.get('last_nudge', agent['created_at']).replace('Z', '+00:00') - ) - idle_time = now - last_nudge - - if idle_time > stuck_threshold: - stuck_workers.append({ - 'repo': repo_name, - 'name': agent_name, - 'task': agent.get('task'), - 'idle_minutes': idle_time.total_seconds() / 60 - }) -``` - -### Get PR Status Summary - -```bash -# Bash/jq - Count PRs by status -cat ~/.multiclaude/state.json | jq -r ' - .repos[].task_history[] | .status -' | sort | uniq -c - -# Output: -# 5 merged -# 2 open -# 1 closed -``` - -## Building a State Reader Library - -### Go Example - -```go -package multiclaude - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - - "github.com/dlorenc/multiclaude/internal/state" - "github.com/fsnotify/fsnotify" -) - -type StateReader struct { - path string - mu sync.RWMutex - state *state.State - onChange func(*state.State) -} - -func NewStateReader(path string) (*StateReader, error) { - r := &StateReader{path: path} - if err := r.reload(); err != nil { - return nil, err - } - return r, nil -} - -func (r *StateReader) reload() error { - data, err := os.ReadFile(r.path) + data, err := os.ReadFile("/home/user/.multiclaude/state.json") if err != nil { - return err + panic(err) } - var s state.State - if err := json.Unmarshal(data, &s); err != nil { - return err + var st state.State + if err := json.Unmarshal(data, &st); err != nil { + panic(err) } - r.mu.Lock() - r.state = &s - r.mu.Unlock() - - return nil -} - -func (r *StateReader) Get() *state.State { - r.mu.RLock() - defer r.mu.RUnlock() - return r.state -} - -func (r *StateReader) Watch(onChange func(*state.State)) error { - r.onChange = onChange - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err + for name := range st.Repos { + fmt.Println("repo", name) } - - if err := watcher.Add(r.path); err != nil { - return err - } - - go func() { - for { - select { - case event := <-watcher.Events: - if event.Op&fsnotify.Write == fsnotify.Write { - r.reload() - if r.onChange != nil { - r.onChange(r.Get()) - } - } - case <-watcher.Errors: - // Handle error - } - } - }() - - return nil } ``` -Usage: -```go -reader, _ := multiclaude.NewStateReader( - filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json") -) - -reader.Watch(func(s *state.State) { - fmt.Printf("State updated: %d repos\n", len(s.Repos)) -}) - -// Query current state -state := reader.Get() -for name, repo := range state.Repos { - fmt.Printf("Repo: %s (%d agents)\n", name, len(repo.Agents)) -} -``` - -## Performance Considerations - -### Read Performance - -- **File Size**: Typically 10-100KB, grows with task history -- **Parse Time**: <1ms for typical state files -- **Watch Overhead**: Minimal with fsnotify/inotify - -### Update Frequency - -The daemon writes to state.json: -- Every agent start/stop -- Every task assignment/completion -- Every status update (every 2 minutes during health checks) -- Every PR created/merged - -**Recommendation:** Use file watching, not polling. Polling < 1s is wasteful. - -### Handling Rapid Updates - -During busy periods (many agents, frequent changes), you may see multiple updates per second. - -**Debouncing Pattern:** - -```javascript -let updateTimeout; -watcher.on('change', () => { - clearTimeout(updateTimeout); - updateTimeout = setTimeout(() => { - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); - processState(state); // Your logic here - }, 100); // Wait 100ms for update burst to finish -}); -``` - -## Atomic Reads - -The daemon uses atomic writes (write to temp, rename), so you'll never read a corrupt file. However: - -1. **During a write**, you might read the old state -2. **After the rename**, you'll read the new state -3. **Never** will you read a partial write - -This means: **No locking required** - just read whenever you want. - -## Schema Evolution - -### Version Compatibility - -Currently, the state file has no explicit version field. If the schema changes: - -1. **Backward-compatible changes** (new fields): Your code ignores unknown fields -2. **Breaking changes** (removed/renamed fields): Will be announced in release notes - -**Future-proofing your code:** - -```javascript -// Defensive access -const agentTask = agent.task || agent.description || 'Unknown'; -const status = entry.status || 'unknown'; -``` - -### Deprecated Fields - -The schema has evolved over time. Some historical notes: - -- `merge_queue_config` was added later - older state files won't have it -- If missing, assume `DefaultMergeQueueConfig()`: `{enabled: true, track_mode: "all"}` - -## Troubleshooting - -### State File Missing - -```bash -# Check if multiclaude is initialized -if [ ! -f ~/.multiclaude/state.json ]; then - echo "Error: multiclaude not initialized" - echo "Run: multiclaude init " - exit 1 -fi -``` - -### State File Permissions - -```bash -# State file should be user-readable -ls -l ~/.multiclaude/state.json -# -rw-r--r-- 1 user user ... - -# If not readable, check daemon logs -tail ~/.multiclaude/daemon.log -``` - -### Parse Errors - +### Python ```python import json -try: - with open(state_path) as f: - state = json.load(f) -except json.JSONDecodeError as e: - # This should never happen due to atomic writes - # If it does, the state file is corrupted - print(f"Error parsing state: {e}") - print("Check daemon logs and consider restarting daemon") -``` - -### Stale Data - -If state seems stale (agents shown as running but they're not): - -```bash -# Trigger daemon health check -multiclaude cleanup --dry-run +from pathlib import Path -# Or force state refresh -pkill -USR1 multiclaude # Send signal to daemon (future feature) +state_path = Path.home() / ".multiclaude" / "state.json" +state = json.loads(state_path.read_text()) +for repo, data in state.get("repos", {}).items(): + print("repo", repo, "agents", list(data.get("agents", {}).keys())) ``` -## Real-World Examples - -### Example 1: Prometheus Exporter - -Export multiclaude metrics to Prometheus: - -```python -from prometheus_client import start_http_server, Gauge -import json, time, os - -# Define metrics -agents_gauge = Gauge('multiclaude_agents_total', 'Number of agents', ['repo', 'type']) -tasks_counter = Gauge('multiclaude_tasks_total', 'Completed tasks', ['repo', 'status']) - -def update_metrics(): - with open(os.path.expanduser('~/.multiclaude/state.json')) as f: - state = json.load(f) - - # Update agent counts - for repo_name, repo in state['repos'].items(): - agent_types = {} - for agent in repo['agents'].values(): - t = agent['type'] - agent_types[t] = agent_types.get(t, 0) + 1 - - for agent_type, count in agent_types.items(): - agents_gauge.labels(repo=repo_name, type=agent_type).set(count) - - # Update task history counts - for repo_name, repo in state['repos'].items(): - status_counts = {} - for entry in repo.get('task_history', []): - s = entry['status'] - status_counts[s] = status_counts.get(s, 0) + 1 - - for status, count in status_counts.items(): - tasks_counter.labels(repo=repo_name, status=status).set(count) - -if __name__ == '__main__': - start_http_server(9090) - while True: - update_metrics() - time.sleep(15) # Update every 15 seconds -``` - -### Example 2: CLI Status Monitor - -Simple CLI tool to show current status: - -```bash -#!/bin/bash -# multiclaude-status.sh - Show active workers - -state=$(cat ~/.multiclaude/state.json) - -echo "=== Active Workers ===" -echo "$state" | jq -r ' - .repos | to_entries[] | - .value.agents | to_entries[] | - select(.value.type == "worker" and .value.pid > 0) | - "\(.key): \(.value.task)" -' - -echo "" -echo "=== Recent Completions ===" -echo "$state" | jq -r ' - .repos[].task_history[] | - select(.status == "merged") | - "\(.name): \(.summary)" -' | tail -5 -``` - -### Example 3: Web Dashboard API - -> **Note:** The reference implementation (`internal/dashboard/`, `cmd/multiclaude-web`) does not exist. -> See WEB_UI_DEVELOPMENT.md for design patterns if building a dashboard in a fork. - -A web dashboard would typically include: -- REST endpoints for repos, agents, history -- Server-Sent Events for live updates -- State watching with fsnotify - -## Related Documentation - -- **[`EXTENSIBILITY.md`](../EXTENSIBILITY.md)** - Overview of all extension points -- **[`WEB_UI_DEVELOPMENT.md`](WEB_UI_DEVELOPMENT.md)** - Building dashboards with state reader -- **[`SOCKET_API.md`](SOCKET_API.md)** - For writing state (not just reading) -- `internal/state/state.go` - Canonical Go schema definition - -## Contributing - -When proposing schema changes: - -1. Update this document first -2. Update `internal/state/state.go` -3. Verify backward compatibility -4. Add migration notes to release notes -5. Update all code examples in this doc +## Updating this doc +- Keep the `state-struct` markers above in sync with `internal/state/state.go`. +- Do **not** add fields here unless they exist in the structs. +- Run `go run ./cmd/verify-docs` after schema changes; CI will block if docs drift. diff --git a/docs/extending/WEB_UI_DEVELOPMENT.md b/docs/extending/WEB_UI_DEVELOPMENT.md index beb3ff1..294b3f3 100644 --- a/docs/extending/WEB_UI_DEVELOPMENT.md +++ b/docs/extending/WEB_UI_DEVELOPMENT.md @@ -1,986 +1,5 @@ -# Web UI Development Guide +# Web UI Development [PLANNED] -> **WARNING: REFERENCE IMPLEMENTATION DOES NOT EXIST** -> -> This document references `cmd/multiclaude-web` and `internal/dashboard/` which do **not exist** in the codebase. -> Per ROADMAP.md, web interfaces and dashboards are explicitly out of scope for upstream multiclaude. -> -> This document is preserved as a design guide for potential fork implementations. +A first-party web UI is not part of the current codebase. Downstream projects can still build dashboards by reading `~/.multiclaude/state.json` and (optionally) calling the socket API documented in `SOCKET_API.md`. -**Extension Point:** Building web dashboards and monitoring UIs (FOR FORKS ONLY) - -This guide shows you how to build web-based user interfaces for multiclaude in a fork. - -**IMPORTANT:** Web UIs are a **fork-only feature**. Upstream multiclaude explicitly rejects web interfaces per ROADMAP.md. This guide is for fork maintainers and downstream projects only. - -## Overview - -Building a web UI for multiclaude is straightforward: -1. Read `state.json` with a file watcher (`fsnotify`) -2. Serve state via REST API -3. (Optional) Add Server-Sent Events for live updates -4. Build a simple HTML/CSS/JS frontend - -**Architecture Benefits:** -- **No daemon dependency**: Read state directly from file -- **Read-only**: Can't break multiclaude operation -- **Simple**: Standard web stack, no special protocols -- **Live updates**: SSE provides real-time updates efficiently - -## Reference Implementation - -The `cmd/multiclaude-web` binary provides: -- Multi-repository dashboard -- Live agent status -- Task history browser -- REST API + SSE -- Single-page app (embedded in binary) - -**Total LOC:** ~500 lines (excluding HTML/CSS) - -## Quick Start - -### Running the Reference Implementation - -```bash -# Build -go build ./cmd/multiclaude-web - -# Run on localhost:8080 -./multiclaude-web - -# Custom port -./multiclaude-web --port 3000 - -# Listen on all interfaces (⚠️ no auth!) -./multiclaude-web --bind 0.0.0.0 - -# Custom state file location -./multiclaude-web --state /path/to/state.json -``` - -### Testing Without Multiclaude - -```bash -# Create fake state for UI development -cat > /tmp/test-state.json <<'EOF' -{ - "repos": { - "test-repo": { - "github_url": "https://github.com/user/repo", - "tmux_session": "mc-test-repo", - "agents": { - "supervisor": { - "type": "supervisor", - "pid": 12345, - "created_at": "2024-01-15T10:00:00Z" - }, - "clever-fox": { - "type": "worker", - "task": "Add authentication", - "pid": 12346, - "created_at": "2024-01-15T10:15:00Z" - } - }, - "task_history": [ - { - "name": "brave-lion", - "task": "Fix bug", - "status": "merged", - "pr_url": "https://github.com/user/repo/pull/42", - "pr_number": 42, - "created_at": "2024-01-14T10:00:00Z", - "completed_at": "2024-01-14T11:00:00Z" - } - ] - } - } -} -EOF - -# Point your UI at test state -./multiclaude-web --state /tmp/test-state.json -``` - -## Architecture - -### Component Diagram - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Browser │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ HTML/CSS/JS (Single Page App) │ │ -│ │ - Repo list │ │ -│ │ - Agent cards │ │ -│ │ - Task history │ │ -│ └───────────┬─────────────────────────────────────────┘ │ -└──────────────┼──────────────────────────────────────────────┘ - │ - │ HTTP (REST + SSE) - │ -┌──────────────▼──────────────────────────────────────────────┐ -│ Web Server (Go) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ API Handler │ │ SSE Broadcaster │ │ -│ │ - GET /api/* │────────▶│ - Event stream │ │ -│ └──────────────────┘ └──────────────────┘ │ -│ │ ▲ │ -│ ▼ │ │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ State Reader │ │ -│ │ - Watches state.json with fsnotify │ │ -│ │ - Caches parsed state │ │ -│ │ - Notifies subscribers on change │ │ -│ └────────────────┬─────────────────────────────────┘ │ -└───────────────────┼──────────────────────────────────────────┘ - │ fsnotify.Watch - │ -┌───────────────────▼──────────────────────────────────────────┐ -│ ~/.multiclaude/state.json │ -│ (Written atomically by daemon) │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Core Components - -1. **StateReader** (`internal/dashboard/reader.go`) - - Watches state file with fsnotify - - Caches parsed state - - Notifies callbacks on changes - - Handles multiple state files (multi-machine support) - -2. **APIHandler** (`internal/dashboard/api.go`) - - REST endpoints for querying state - - SSE endpoint for live updates - - JSON serialization - -3. **Server** (`internal/dashboard/server.go`) - - HTTP server setup - - Static file serving (embedded frontend) - - Routing - -4. **Frontend** (`internal/dashboard/web/`) - - HTML/CSS/JS single-page app - - Connects to REST API - - Subscribes to SSE for live updates - -## Building Your Own UI - -### Step 1: State Reader - -```go -package main - -import ( - "encoding/json" - "log" - "os" - "sync" - - "github.com/fsnotify/fsnotify" - "github.com/dlorenc/multiclaude/internal/state" -) - -type StateReader struct { - path string - watcher *fsnotify.Watcher - mu sync.RWMutex - state *state.State - onChange func(*state.State) -} - -func NewStateReader(path string) (*StateReader, error) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - - reader := &StateReader{ - path: path, - watcher: watcher, - } - - // Initial read - if err := reader.reload(); err != nil { - return nil, err - } - - // Watch for changes - if err := watcher.Add(path); err != nil { - return nil, err - } - - // Start watch loop - go reader.watchLoop() - - return reader, nil -} - -func (r *StateReader) reload() error { - data, err := os.ReadFile(r.path) - if err != nil { - return err - } - - var s state.State - if err := json.Unmarshal(data, &s); err != nil { - return err - } - - r.mu.Lock() - r.state = &s - r.mu.Unlock() - - return nil -} - -func (r *StateReader) watchLoop() { - for { - select { - case event := <-r.watcher.Events: - if event.Op&fsnotify.Write == fsnotify.Write { - r.reload() - if r.onChange != nil { - r.onChange(r.Get()) - } - } - case err := <-r.watcher.Errors: - log.Printf("Watcher error: %v", err) - } - } -} - -func (r *StateReader) Get() *state.State { - r.mu.RLock() - defer r.mu.RUnlock() - return r.state -} - -func (r *StateReader) Watch(onChange func(*state.State)) { - r.onChange = onChange -} - -func (r *StateReader) Close() error { - return r.watcher.Close() -} -``` - -### Step 2: REST API - -```go -package main - -import ( - "encoding/json" - "net/http" -) - -type APIHandler struct { - reader *StateReader -} - -func NewAPIHandler(reader *StateReader) *APIHandler { - return &APIHandler{reader: reader} -} - -// GET /api/repos -func (h *APIHandler) HandleRepos(w http.ResponseWriter, r *http.Request) { - state := h.reader.Get() - - type RepoInfo struct { - Name string `json:"name"` - GithubURL string `json:"github_url"` - AgentCount int `json:"agent_count"` - } - - repos := []RepoInfo{} - for name, repo := range state.Repos { - repos = append(repos, RepoInfo{ - Name: name, - GithubURL: repo.GithubURL, - AgentCount: len(repo.Agents), - }) - } - - h.writeJSON(w, repos) -} - -// GET /api/repos/{name}/agents -func (h *APIHandler) HandleAgents(w http.ResponseWriter, r *http.Request) { - state := h.reader.Get() - - // Extract repo name from path (you'll need a router for this) - repoName := extractRepoName(r.URL.Path) - - repo, ok := state.Repos[repoName] - if !ok { - http.Error(w, "Repository not found", http.StatusNotFound) - return - } - - h.writeJSON(w, repo.Agents) -} - -// GET /api/repos/{name}/history -func (h *APIHandler) HandleHistory(w http.ResponseWriter, r *http.Request) { - state := h.reader.Get() - - repoName := extractRepoName(r.URL.Path) - repo, ok := state.Repos[repoName] - if !ok { - http.Error(w, "Repository not found", http.StatusNotFound) - return - } - - // Return task history (most recent first) - history := repo.TaskHistory - if history == nil { - history = []state.TaskHistoryEntry{} - } - - // Reverse for most recent first - reversed := make([]state.TaskHistoryEntry, len(history)) - for i, entry := range history { - reversed[len(history)-1-i] = entry - } - - h.writeJSON(w, reversed) -} - -func (h *APIHandler) writeJSON(w http.ResponseWriter, data interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) -} -``` - -### Step 3: Server-Sent Events (Live Updates) - -```go -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "sync" -) - -type APIHandler struct { - reader *StateReader - mu sync.RWMutex - clients map[chan []byte]bool -} - -func NewAPIHandler(reader *StateReader) *APIHandler { - handler := &APIHandler{ - reader: reader, - clients: make(map[chan []byte]bool), - } - - // Register for state changes - reader.Watch(func(s *state.State) { - handler.broadcastUpdate(s) - }) - - return handler -} - -// GET /api/events - SSE endpoint -func (h *APIHandler) HandleEvents(w http.ResponseWriter, r *http.Request) { - // Set SSE headers - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Create channel for this client - clientChan := make(chan []byte, 10) - - h.mu.Lock() - h.clients[clientChan] = true - h.mu.Unlock() - - // Remove client on disconnect - defer func() { - h.mu.Lock() - delete(h.clients, clientChan) - close(clientChan) - h.mu.Unlock() - }() - - // Send initial state - state := h.reader.Get() - data, _ := json.Marshal(state) - fmt.Fprintf(w, "data: %s\n\n", data) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Listen for updates or client disconnect - for { - select { - case msg := <-clientChan: - fmt.Fprintf(w, "data: %s\n\n", msg) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - case <-r.Context().Done(): - return - } - } -} - -func (h *APIHandler) broadcastUpdate(s *state.State) { - data, err := json.Marshal(s) - if err != nil { - return - } - - h.mu.RLock() - defer h.mu.RUnlock() - - for client := range h.clients { - select { - case client <- data: - default: - // Client buffer full, skip this update - } - } -} -``` - -### Step 4: Main Server - -```go -package main - -import ( - "embed" - "log" - "net/http" - "os" - "path/filepath" -) - -//go:embed web/* -var webFiles embed.FS - -func main() { - // Create state reader - statePath := filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json") - reader, err := NewStateReader(statePath) - if err != nil { - log.Fatalf("Failed to create state reader: %v", err) - } - defer reader.Close() - - // Create API handler - api := NewAPIHandler(reader) - - // Setup routes - http.HandleFunc("/api/repos", api.HandleRepos) - http.HandleFunc("/api/repos/", api.HandleAgents) // Handles /api/repos/{name}/* - http.HandleFunc("/api/events", api.HandleEvents) - - // Serve static files - http.Handle("/", http.FileServer(http.FS(webFiles))) - - // Start server - addr := ":8080" - log.Printf("Server starting on %s", addr) - log.Fatal(http.ListenAndServe(addr, nil)) -} -``` - -### Step 5: Frontend (HTML/JS) - -```html - - - - Multiclaude Dashboard - - - -

- Multiclaude Dashboard - -

- -
- - - - -``` - -## REST API Reference - -### Endpoints - -| Endpoint | Method | Description | Response | -|----------|--------|-------------|----------| -| `/api/repos` | GET | List all repositories | Array of repo info | -| `/api/repos/{name}` | GET | Get repository details | Repository object | -| `/api/repos/{name}/agents` | GET | Get agents for repo | Map of agents | -| `/api/repos/{name}/history` | GET | Get task history | Array of history entries | -| `/api/events` | GET | SSE stream of state updates | Server-Sent Events | - -### Example Responses - -#### GET /api/repos - -```json -[ - { - "name": "my-app", - "github_url": "https://github.com/user/my-app", - "agent_count": 3 - } -] -``` - -#### GET /api/repos/my-app/agents - -```json -{ - "supervisor": { - "type": "supervisor", - "pid": 12345, - "created_at": "2024-01-15T10:00:00Z" - }, - "clever-fox": { - "type": "worker", - "task": "Add authentication", - "pid": 12346, - "created_at": "2024-01-15T10:15:00Z" - } -} -``` - -#### GET /api/repos/my-app/history - -```json -[ - { - "name": "brave-lion", - "task": "Fix bug", - "status": "merged", - "pr_url": "https://github.com/user/my-app/pull/42", - "pr_number": 42, - "created_at": "2024-01-14T10:00:00Z", - "completed_at": "2024-01-14T11:00:00Z" - } -] -``` - -## Frontend Frameworks - -### React Example - -```jsx -import React, { useState, useEffect } from 'react'; - -function Dashboard() { - const [repos, setRepos] = useState([]); - - useEffect(() => { - // Fetch initial state - fetch('/api/repos') - .then(res => res.json()) - .then(setRepos); - - // Subscribe to SSE - const eventSource = new EventSource('/api/events'); - eventSource.onmessage = (event) => { - const state = JSON.parse(event.data); - const repos = Object.entries(state.repos || {}).map(([name, repo]) => ({ - name, - agentCount: Object.keys(repo.agents || {}).length - })); - setRepos(repos); - }; - - return () => eventSource.close(); - }, []); - - return ( -
-

Multiclaude Dashboard

- {repos.map(repo => ( -
-

{repo.name}

-

{repo.agentCount} agents

-
- ))} -
- ); -} -``` - -### Vue Example - -```vue - - - -``` - -## Advanced Features - -### Multi-Machine Support - -```go -// Watch multiple state files -reader, _ := dashboard.NewStateReader([]string{ - "/home/user/.multiclaude/state.json", - "ssh://dev-box/home/user/.multiclaude/state.json", // Future: remote support -}) - -// Aggregated state includes machine identifier -type AggregatedState struct { - Machines map[string]*MachineState -} - -type MachineState struct { - Path string - Repos map[string]*state.Repository -} -``` - -### Filtering and Search - -```javascript -// Frontend: Filter agents by type -const workers = Object.entries(agents) - .filter(([_, agent]) => agent.type === 'worker'); - -// Backend: Add query parameters -func (h *APIHandler) HandleAgents(w http.ResponseWriter, r *http.Request) { - agentType := r.URL.Query().Get("type") // ?type=worker - - // Filter agents... -} -``` - -### Historical Charts - -```javascript -// Fetch history and render chart -async function fetchHistory(repo) { - const res = await fetch(`/api/repos/${repo}/history`); - const history = await res.json(); - - // Count by status - const statusCounts = {}; - history.forEach(entry => { - statusCounts[entry.status] = (statusCounts[entry.status] || 0) + 1; - }); - - // Render with Chart.js, D3, etc. - renderChart(statusCounts); -} -``` - -## Security - -### Authentication (Not Implemented) - -The reference implementation has **no authentication**. For production: - -```go -// Add basic auth middleware -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := r.BasicAuth() - if !ok || user != "admin" || pass != "secret" { - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -http.Handle("/", authMiddleware(handler)) -``` - -### HTTPS - -```go -// Generate self-signed cert for development -// openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 - -log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil)) -``` - -### CORS (for separate frontend) - -```go -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - if r.Method == "OPTIONS" { - return - } - next.ServeHTTP(w, r) - }) -} -``` - -## Deployment - -### SSH Tunnel (Recommended) - -```bash -# On remote machine -./multiclaude-web - -# On local machine -ssh -L 8080:localhost:8080 user@remote-machine - -# Browse to http://localhost:8080 -``` - -### Docker - -```dockerfile -FROM golang:1.21 AS builder -WORKDIR /app -COPY . . -RUN go build ./cmd/multiclaude-web - -FROM debian:12-slim -COPY --from=builder /app/multiclaude-web /usr/local/bin/ -CMD ["multiclaude-web", "--bind", "0.0.0.0"] -``` - -### systemd Service - -```ini -[Unit] -Description=Multiclaude Web Dashboard -After=network.target - -[Service] -Type=simple -User=multiclaude -ExecStart=/usr/local/bin/multiclaude-web -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` - -## Performance - -### State File Size Growth - -- **Typical**: 10-100KB -- **With large history**: 100KB-1MB -- **Memory impact**: Minimal (parsed once, cached) - -### SSE Connection Limits - -- **Per server**: Thousands of concurrent connections -- **Per client**: Browser limit ~6 connections per domain - -### Update Frequency - -- State updates: Every agent action (~1-10/minute) -- SSE broadcasts: Debounced to avoid spam -- Client receives: Latest state on each update - -## Troubleshooting - -### SSE Not Working - -```javascript -// Check SSE connection -eventSource.onerror = (err) => { - console.error('SSE error:', err); - - // Fall back to polling - setInterval(fetchState, 5000); -}; -``` - -### State File Not Found - -```go -statePath := filepath.Join(os.Getenv("HOME"), ".multiclaude/state.json") -if _, err := os.Stat(statePath); os.IsNotExist(err) { - log.Fatalf("State file not found: %s\nIs multiclaude initialized?", statePath) -} -``` - -### CORS Issues - -```javascript -// If API and frontend on different ports -fetch('http://localhost:8080/api/repos', { - mode: 'cors' -}) -``` - -## Related Documentation - -- **[`EXTENSIBILITY.md`](../EXTENSIBILITY.md)** - Overview of extension points -- **[`STATE_FILE_INTEGRATION.md`](STATE_FILE_INTEGRATION.md)** - State schema reference -- **[`WEB_DASHBOARD.md`](../WEB_DASHBOARD.md)** - Reference implementation docs -- `cmd/multiclaude-web/` - Complete working example -- `internal/dashboard/` - Backend implementation - -## Contributing - -Improvements to the reference implementation are welcome: - -1. **UI enhancements**: Better styling, charts, filters -2. **Features**: Search, notifications, keyboard shortcuts -3. **Accessibility**: ARIA labels, keyboard navigation -4. **Documentation**: More examples, troubleshooting tips - -Submit PRs to the fork repository (marked `[fork-only]`). +If a web UI is added upstream, document the real API surface here and keep examples aligned with the implemented handlers. From 6cadc237a545f0cb7b23937ccab7bdd9576b53a0 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 18:18:22 +0000 Subject: [PATCH 03/15] Fix docs verification and CI failure for PR #289 --- cmd/verify-docs/main.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/verify-docs/main.go b/cmd/verify-docs/main.go index fc2f245..59aabcc 100644 --- a/cmd/verify-docs/main.go +++ b/cmd/verify-docs/main.go @@ -2,14 +2,13 @@ // // This tool checks: // - State schema fields match documentation -// - Event types match documentation // - Socket API commands match documentation // - File paths in docs exist and are correct // // Usage: // // go run cmd/verify-docs/main.go -// go run cmd/verify-docs/main.go --fix # Auto-update docs (future) +// go run cmd/verify-docs/main.go --fix // Auto-update docs (future) package main import ( @@ -172,7 +171,8 @@ func verifyFilePaths() Verification { "docs/extending/SOCKET_API.md", } - filePattern := regexp.MustCompile("`((?:internal|pkg|cmd)/[^`]+\\.go)`") + // Use raw string literal for regex to avoid double escaping hell + filePattern := regexp.MustCompile(`((?:internal|pkg|cmd)/[^` + "`" + `]+\.go)`) missing := []string{} @@ -358,7 +358,10 @@ func parseSocketCommandsFromDocs() ([]string, error) { // parseListFromComment extracts a newline-delimited list from an HTML comment label. func parseListFromComment(content, label string) []string { - pattern := fmt.Sprintf("(?s)", regexp.QuoteMeta(label)) + // Use fmt.Sprintf to construct regex, escaping label but using raw strings for regex syntax + // (?s) dot matches newline + // + pattern := fmt.Sprintf(`(?s)`, regexp.QuoteMeta(label)) re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(content) if len(matches) < 2 { From 9afbf27fa9edfad1b21d6469b12561f3ea298834 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 18:37:57 +0000 Subject: [PATCH 04/15] Use double quotes for regex to avoid syntax errors --- cmd/verify-docs/main.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/verify-docs/main.go b/cmd/verify-docs/main.go index 59aabcc..fc15122 100644 --- a/cmd/verify-docs/main.go +++ b/cmd/verify-docs/main.go @@ -171,8 +171,8 @@ func verifyFilePaths() Verification { "docs/extending/SOCKET_API.md", } - // Use raw string literal for regex to avoid double escaping hell - filePattern := regexp.MustCompile(`((?:internal|pkg|cmd)/[^` + "`" + `]+\.go)`) + // Use double-quoted string with explicit escapes for safety + filePattern := regexp.MustCompile("((?:internal|pkg|cmd)/[^`]+\\.go)") missing := []string{} @@ -358,10 +358,10 @@ func parseSocketCommandsFromDocs() ([]string, error) { // parseListFromComment extracts a newline-delimited list from an HTML comment label. func parseListFromComment(content, label string) []string { - // Use fmt.Sprintf to construct regex, escaping label but using raw strings for regex syntax + // Use fmt.Sprintf with double-quoted strings and explicit escapes // (?s) dot matches newline - // - pattern := fmt.Sprintf(`(?s)`, regexp.QuoteMeta(label)) + // + pattern := fmt.Sprintf("(?s)", regexp.QuoteMeta(label)) re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(content) if len(matches) < 2 { From 762ef6d4049ff13f291db9bdbbcb2fc5fda16e49 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 18:40:07 +0000 Subject: [PATCH 05/15] Add Makefile with verify-docs pre-commit check --- Makefile | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72fc1bf --- /dev/null +++ b/Makefile @@ -0,0 +1,125 @@ +# Makefile for multiclaude - Local CI Guard Rails +# Run these targets to verify changes before pushing + +.PHONY: help build test unit-tests e2e-tests verify-docs coverage check-all pre-commit clean + +# Default target +help: + @echo "Multiclaude Local CI Guard Rails" + @echo "" + @echo "Targets that mirror CI checks:" + @echo " make build - Build all packages (CI: Build job)" + @echo " make unit-tests - Run unit tests (CI: Unit Tests job)" + @echo " make e2e-tests - Run E2E tests (CI: E2E Tests job)" + @echo " make verify-docs - Check generated docs are up to date (CI: Verify Generated Docs job)" + @echo " make coverage - Run coverage check (CI: Coverage Check job)" + @echo "" + @echo "Comprehensive checks:" + @echo " make check-all - Run all CI checks locally (recommended before push)" + @echo " make pre-commit - Fast checks suitable for git pre-commit hook" + @echo "" + @echo "Setup:" + @echo " make install-hooks - Install git pre-commit hook" + @echo "" + @echo "Other:" + @echo " make test - Alias for unit-tests" + @echo " make clean - Clean build artifacts" + +# Build - matches CI build job +build: + @echo "==> Building all packages..." + @go build -v ./... + @echo "✓ Build successful" + +# Unit tests - matches CI unit-tests job +unit-tests: + @echo "==> Running unit tests..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @go test -coverprofile=coverage.out -covermode=atomic ./internal/... ./pkg/... + @go tool cover -func=coverage.out | tail -1 + @echo "✓ Unit tests passed" + +# E2E tests - matches CI e2e-tests job +e2e-tests: + @echo "==> Running E2E tests..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @git config user.email >/dev/null 2>&1 || git config --global user.email "ci@local.dev" + @git config user.name >/dev/null 2>&1 || git config --global user.name "Local CI" + @go test -v ./test/... + @echo "✓ E2E tests passed" + +# Verify generated docs - matches CI verify-generated-docs job +verify-docs: + @echo "==> Verifying generated docs are up to date..." + @go generate ./pkg/config/... + @if ! git diff --quiet docs/DIRECTORY_STRUCTURE.md; then \ + echo "Error: docs/DIRECTORY_STRUCTURE.md is out of date!"; \ + echo "Run 'go generate ./pkg/config/...' or 'make generate' and commit the changes."; \ + echo ""; \ + echo "Diff:"; \ + git diff docs/DIRECTORY_STRUCTURE.md; \ + exit 1; \ + fi + @echo "==> Verifying extension documentation consistency..." + @go run ./cmd/verify-docs + @echo "✓ Generated docs are up to date" + +# Coverage check - matches CI coverage-check job +coverage: + @echo "==> Checking coverage thresholds..." + @command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required for tests. Install with: sudo apt-get install tmux"; exit 1; } + @go test -coverprofile=coverage.out -covermode=atomic ./internal/... ./pkg/... + @echo "" + @echo "Coverage summary:" + @go tool cover -func=coverage.out | grep "total:" || true + @echo "" + @echo "Per-package coverage:" + @go test -cover ./internal/... ./pkg/... 2>&1 | grep "coverage:" | sort + @echo "✓ Coverage check complete" + +# Helper to regenerate docs +generate: + @echo "==> Regenerating documentation..." + @go generate ./pkg/config/... + @echo "✓ Documentation regenerated" + +# Alias for unit-tests +test: unit-tests + +# Pre-commit: Fast checks suitable for git hook +# Runs build + unit tests + verify docs (skips slower e2e tests) +pre-commit: build unit-tests verify-docs + @echo "" + @echo "✓ All pre-commit checks passed" + +# Check all: Complete CI validation locally +# Runs all checks that CI will run +check-all: build unit-tests e2e-tests verify-docs coverage + @echo "" + @echo "==========================================" + @echo "✓ All CI checks passed locally!" + @echo "Your changes are ready to push." + @echo "==========================================" + +# Install git hooks +install-hooks: + @echo "==> Installing git pre-commit hook..." + @mkdir -p .git/hooks + @if [ -f .git/hooks/pre-commit ]; then \ + echo "Warning: .git/hooks/pre-commit already exists"; \ + echo "Backing up to .git/hooks/pre-commit.backup"; \ + cp .git/hooks/pre-commit .git/hooks/pre-commit.backup; \ + fi + @cp scripts/pre-commit.sh .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "✓ Git pre-commit hook installed" + @echo "" + @echo "The hook will run 'make pre-commit' before each commit." + @echo "To skip the hook temporarily, use: git commit --no-verify" + +# Clean build artifacts +clean: + @echo "==> Cleaning build artifacts..." + @rm -f coverage.out + @go clean -cache + @echo "✓ Clean complete" From abbabec1d36d21ca7c105afd78b71cdeb5d6f718 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 24 Jan 2026 18:41:37 +0000 Subject: [PATCH 06/15] Fix unused verbose variable in verify-docs --- cmd/verify-docs/main.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmd/verify-docs/main.go b/cmd/verify-docs/main.go index fc15122..7e1f5c8 100644 --- a/cmd/verify-docs/main.go +++ b/cmd/verify-docs/main.go @@ -100,6 +100,9 @@ func verifyStateSchema() Verification { var extraFields []string for name, fields := range codeStructs { + if *verbose { + fmt.Printf("Verifying struct: %s\n", name) + } docFields := docStructs[name] missingFields = append(missingFields, diffListPrefixed(fields, docFields, name)...) extraFields = append(extraFields, diffListPrefixed(docFields, fields, name)...) @@ -143,6 +146,10 @@ func verifySocketCommands() Verification { return v } + if *verbose { + fmt.Printf("Found %d commands in code, %d in docs\n", len(codeCommands), len(docCommands)) + } + missing := diffList(codeCommands, docCommands) extra := diffList(docCommands, codeCommands) @@ -177,6 +184,9 @@ func verifyFilePaths() Verification { missing := []string{} for _, docFile := range docFiles { + if *verbose { + fmt.Printf("Checking references in %s\n", docFile) + } content, err := os.ReadFile(docFile) if err != nil { continue // Skip missing docs From bc3f9bb2877de7409993e6192e0cf0a9ee91c15a Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Fri, 23 Jan 2026 09:29:03 -0800 Subject: [PATCH 07/15] fix: Standardize worker branch naming to multiclaude/ prefix Fixes branch naming inconsistency by standardizing on 'multiclaude/' prefix. Maintains backward compatibility for cleanup of legacy 'work/' branches. Co-Authored-By: Claude Sonnet 4.5 --- internal/cli/cli.go | 12 ++-- internal/daemon/daemon.go | 4 +- internal/errors/errors_test.go | 6 +- internal/state/state_test.go | 8 +-- internal/worktree/worktree.go | 2 +- internal/worktree/worktree_test.go | 106 ++++++++++++++--------------- test/e2e_test.go | 2 +- 7 files changed, 72 insertions(+), 68 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d1c8b02..6296e22 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1943,7 +1943,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)") } } @@ -2014,7 +2014,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) @@ -4740,8 +4740,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 diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d64b918..6075bc0 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1432,7 +1432,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 } } @@ -1582,7 +1582,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.Response{Success: false, Error: fmt.Sprintf("failed to create worktree: %v", err)} } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 36a41d4..b773472 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", ""}, 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 857113d..529ea71 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -169,7 +169,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) } From 886162537154f5e22eb578358830a60c96db5758 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Mon, 26 Jan 2026 00:19:12 +0000 Subject: [PATCH 08/15] Fix reference to non-existent EXTENSION_DOCUMENTATION_SUMMARY.md The comment at internal/cli/cli.go:5128 referenced docs/EXTENSION_DOCUMENTATION_SUMMARY.md which doesn't exist. Updated the comment to reference the actual existing extension docs (docs/extending/SOCKET_API.md and docs/extending/STATE_FILE_INTEGRATION.md) that need to be kept in sync when CLI commands affecting extension surfaces change. Co-Authored-By: Claude Sonnet 4.5 --- internal/cli/cli.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 64219af..b468660 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5124,10 +5124,10 @@ func (c *CLI) showDocs(args []string) error { } // GenerateDocumentation generates markdown documentation for all CLI commands. -// NOTE: This markdown is injected into agent prompts and extension docs. When -// adding or changing commands/flags, ensure docs/EXTENSION_DOCUMENTATION_SUMMARY.md -// stays accurate and rerun `go run ./cmd/verify-docs` so downstream tools/LLMs -// stay current. +// 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 From e447518e8b6c592cfc570495d8735372f4c8c011 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Tue, 27 Jan 2026 00:27:29 +0000 Subject: [PATCH 09/15] feat: enhance repair command to recreate core agents and default workspace Improve the repair command to be more comprehensive by ensuring core agents and a default workspace exist after cleanup. This enhancement aligns with ROADMAP.md P1 "Agent restart" by making repair more robust and reducing the need for manual intervention. Changes: - CLI: Add ensureCoreAgents() and ensureDefaultWorkspace() helpers - CLI: Update localRepair() to recreate missing core agents - CLI: Create default workspace "my-default-2" if none exist - Daemon: Add ensureCoreAgents() and ensureDefaultWorkspace() methods - Daemon: Update handleRepairState() to recreate missing agents - Both: Improve output to show what was removed and what was created - Tests: Add comprehensive tests for all scenarios Key Features: 1. Recreates missing supervisor agent if absent 2. Recreates missing merge-queue (non-fork) or pr-shepherd (fork) 3. Creates default workspace if no workspaces exist 4. Does not duplicate existing agents/workspaces 5. Provides detailed output showing: - Removed: dead agents - Cleaned: orphaned resources - Created: core agents and workspaces Test Coverage: - TestRepairEnsuringCoreAgents: Verifies core agents are created - TestRepairEnsuringPRShepherdInForkMode: Fork mode verification - TestRepairDoesNotDuplicateAgents: Prevents duplicates Co-Authored-By: Claude Sonnet 4.5 --- internal/cli/cli.go | 335 +++++++++++++++++++++++++++++++++++++- internal/cli/cli_test.go | 265 ++++++++++++++++++++++++++++++ internal/daemon/daemon.go | 242 ++++++++++++++++++++++++++- 3 files changed, 831 insertions(+), 11 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..b725ebd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5289,11 +5289,25 @@ func (c *CLI) repair(args []string) error { fmt.Println("✓ State repaired successfully") if data, ok := resp.Data.(map[string]interface{}); ok { - if removed, ok := data["agents_removed"].(float64); ok && removed > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", int(removed)) + removed := int(data["agents_removed"].(float64)) + fixed := int(data["issues_fixed"].(float64)) + created, _ := data["agents_created"].(float64) + wsCreated, _ := data["workspaces_created"].(float64) + + if removed > 0 { + fmt.Printf(" Removed: %d dead agent(s)\n", removed) + } + if fixed > 0 { + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", fixed) + } + if created > 0 { + fmt.Printf(" Created: %d core agent(s)\n", int(created)) } - if fixed, ok := data["issues_fixed"].(float64); ok && fixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", int(fixed)) + if wsCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", int(wsCreated)) + } + if removed == 0 && fixed == 0 && created == 0 && wsCreated == 0 { + fmt.Println(" No issues found, no changes needed") } } @@ -5473,6 +5487,35 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("Or use: multiclaude stop-all") } + // Ensure core agents exist for each repository + agentsCreated := 0 + workspacesCreated := 0 + for _, repoName := range st.ListRepos() { + if verbose { + fmt.Printf("\nEnsuring core agents for repository: %s\n", repoName) + } + + // Ensure core agents (supervisor, merge-queue/pr-shepherd) + created, err := c.ensureCoreAgents(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure core agents: %v\n", err) + } + } else { + agentsCreated += created + } + + // Ensure default workspace exists + wsCreated, err := c.ensureDefaultWorkspace(st, repoName, verbose) + if err != nil { + if verbose { + fmt.Printf(" Warning: failed to ensure default workspace: %v\n", err) + } + } else if wsCreated { + workspacesCreated++ + } + } + // Save updated state if err := st.Save(); err != nil { return fmt.Errorf("failed to save repaired state: %w", err) @@ -5480,18 +5523,294 @@ func (c *CLI) localRepair(verbose bool) error { fmt.Println("\n✓ Local repair completed") if agentsRemoved > 0 { - fmt.Printf(" Removed %d dead agent(s)\n", agentsRemoved) + fmt.Printf(" Removed: %d dead agent(s)\n", agentsRemoved) } if issuesFixed > 0 { - fmt.Printf(" Fixed %d issue(s)\n", issuesFixed) + fmt.Printf(" Cleaned: %d orphaned resource(s)\n", issuesFixed) + } + if agentsCreated > 0 { + fmt.Printf(" Created: %d core agent(s)\n", agentsCreated) + } + if workspacesCreated > 0 { + fmt.Printf(" Created: %d default workspace(s)\n", workspacesCreated) } - if agentsRemoved == 0 && issuesFixed == 0 { - fmt.Println(" No issues found") + if agentsRemoved == 0 && issuesFixed == 0 && agentsCreated == 0 && workspacesCreated == 0 { + fmt.Println(" No issues found, no changes needed") } return nil } +// ensureCoreAgents ensures that all core agents (supervisor, merge-queue/pr-shepherd) exist +// for a repository. Returns counts of agents created. +func (c *CLI) ensureCoreAgents(st *state.State, repoName string, verbose bool) (int, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return 0, fmt.Errorf("repository %s not found in state", repoName) + } + + created := 0 + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + tmuxClient := tmux.NewClient() + + // Check if session exists + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping core agent creation\n", tmuxSession) + } + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + if verbose { + fmt.Println(" Creating missing supervisor agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "supervisor", state.AgentTypeSupervisor, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + if verbose { + fmt.Println(" Creating missing pr-shepherd agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "pr-shepherd", state.AgentTypePRShepherd, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + if verbose { + fmt.Println(" Creating missing merge-queue agent...") + } + if err := c.createCoreAgent(st, repo, repoName, repoPath, "merge-queue", state.AgentTypeMergeQueue, tmuxClient); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// createCoreAgent creates a core agent (supervisor, merge-queue, or pr-shepherd) +func (c *CLI) createCoreAgent(st *state.State, repo *state.Repository, repoName, repoPath, agentName string, agentType state.AgentType, tmuxClient *tmux.Client) error { + tmuxSession := repo.TmuxSession + + // Check if window already exists + hasWindow, _ := tmuxClient.HasWindow(context.Background(), tmuxSession, agentName) + if !hasWindow { + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", agentName, "-c", repoPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create tmux window: %w", err) + } + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + var promptFile string + switch agentType { + case state.AgentTypeSupervisor: + promptFile, err = c.writePromptFile(repoPath, state.AgentTypeSupervisor, agentName) + case state.AgentTypeMergeQueue: + mqConfig := repo.MergeQueueConfig + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + promptFile, err = c.writeMergeQueuePromptFile(repoPath, agentName, mqConfig) + case state.AgentTypePRShepherd: + psConfig := repo.PRShepherdConfig + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + promptFile, err = c.writePRShepherdPromptFile(repoPath, agentName, psConfig, repo.ForkConfig) + default: + return fmt.Errorf("unsupported agent type: %s", agentType) + } + if err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, repoPath); err != nil && agentType == state.AgentTypeSupervisor { + // Only warn for supervisor + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, agentName, repoPath, sessionID, promptFile, repoName, "") + if err != nil { + return fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, agentName, repoName, agentName, string(agentType)); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register agent with state + agent := state.Agent{ + Type: agentType, + WorktreePath: repoPath, + TmuxWindow: agentName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, agentName, agent); err != nil { + return fmt.Errorf("failed to add agent to state: %w", err) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (c *CLI) ensureDefaultWorkspace(st *state.State, repoName string, verbose bool) (bool, error) { + repo, exists := st.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Create default workspace + workspaceName := "my-default-2" + if verbose { + fmt.Printf(" Creating default workspace '%s'...\n", workspaceName) + } + + repoPath := c.paths.RepoDir(repoName) + tmuxSession := repo.TmuxSession + + // Check if session exists + tmuxClient := tmux.NewClient() + hasSession, err := tmuxClient.HasSession(context.Background(), tmuxSession) + if err != nil || !hasSession { + if verbose { + fmt.Printf(" Tmux session %s not found, skipping workspace creation\n", tmuxSession) + } + return false, nil + } + + // Create worktree + wt := worktree.NewManager(repoPath) + wtPath := c.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window + cmd := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, workspaceName) + if err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + fmt.Printf("Warning: failed to copy hooks config: %v\n", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + claudeBinary, err := c.getClaudeBinary() + if err != nil { + return false, fmt.Errorf("failed to resolve claude binary: %w", err) + } + + pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, workspaceName, wtPath, sessionID, promptFile, repoName, "") + if err != nil { + return false, fmt.Errorf("failed to start Claude: %w", err) + } + + // Set up output capture + if err := c.setupOutputCapture(tmuxSession, workspaceName, repoName, workspaceName, "workspace"); err != nil { + fmt.Printf("Warning: failed to setup output capture: %v\n", err) + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := st.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil +} + // restartClaude restarts Claude in the current agent context. // It auto-detects whether to use --resume or --session-id based on session history. func (c *CLI) restartClaude(args []string) error { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 498577d..eb9fc14 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1034,6 +1034,271 @@ func TestCLIRepairCommand(t *testing.T) { } } +func TestRepairEnsuringCoreAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository to state + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify merge-queue was created (in non-fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created by repair") + } + + // Verify default workspace was created + hasWorkspace := false + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + if !hasWorkspace { + t.Errorf("Default workspace was not created by repair") + } +} + +func TestRepairEnsuringPRShepherdInForkMode(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository in fork mode + repoName := "test-fork-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-fork-repo" + + // Add repository to state (fork mode) + repo := &state.Repository{ + GithubURL: "https://github.com/test/fork", + TmuxSession: tmuxSession, + Agents: make(map[string]state.Agent), + MergeQueueConfig: state.MergeQueueConfig{Enabled: false}, + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{ + IsFork: true, + UpstreamURL: "https://github.com/upstream/repo", + UpstreamOwner: "upstream", + UpstreamRepo: "repo", + }, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session for testing + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession).Run(); err != nil { + t.Skipf("Skipping test: tmux not available or session creation failed: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Run repair (should create supervisor and pr-shepherd, NOT merge-queue) + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor was created + updatedRepo, exists := st.GetRepo(repoName) + if !exists { + t.Fatalf("Repository not found after repair") + } + + if _, exists := updatedRepo.Agents["supervisor"]; !exists { + t.Errorf("Supervisor agent was not created by repair") + } + + // Verify pr-shepherd was created (in fork mode) + if _, exists := updatedRepo.Agents["pr-shepherd"]; !exists { + t.Errorf("PR-shepherd agent was not created by repair in fork mode") + } + + // Verify merge-queue was NOT created (fork mode) + if _, exists := updatedRepo.Agents["merge-queue"]; exists { + t.Errorf("Merge-queue agent should not be created in fork mode") + } +} + +func TestRepairDoesNotDuplicateAgents(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Load state + st, err := cli.loadState() + if err != nil { + t.Fatalf("Failed to load state: %v", err) + } + + // Initialize a test repository + repoName := "test-repo" + repoPath := cli.paths.RepoDir(repoName) + tmuxSession := "mc-test-repo" + + // Add repository with existing supervisor + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: tmuxSession, + Agents: map[string]state.Agent{ + "supervisor": { + Type: state.AgentTypeSupervisor, + WorktreePath: repoPath, + TmuxWindow: "supervisor", + SessionID: "existing-session-id", + PID: 12345, + }, + "my-workspace": { + Type: state.AgentTypeWorkspace, + WorktreePath: cli.paths.AgentWorktree(repoName, "my-workspace"), + TmuxWindow: "my-workspace", + SessionID: "workspace-session-id", + PID: 12346, + }, + }, + MergeQueueConfig: state.DefaultMergeQueueConfig(), + PRShepherdConfig: state.DefaultPRShepherdConfig(), + ForkConfig: state.ForkConfig{IsFork: false}, + } + if err := st.AddRepo(repoName, repo); err != nil { + t.Fatalf("Failed to add repo: %v", err) + } + + // Create tmux session + if err := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession, "-n", "supervisor").Run(); err != nil { + t.Skipf("Skipping test: tmux not available: %v", err) + } + defer exec.Command("tmux", "kill-session", "-t", tmuxSession).Run() + + // Create workspace window + if err := exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", "my-workspace").Run(); err != nil { + t.Fatalf("Failed to create workspace window: %v", err) + } + + // Create repository directory and initialize git + if err := os.MkdirAll(repoPath, 0755); err != nil { + t.Fatalf("Failed to create repo directory: %v", err) + } + setupTestRepo(t, repoPath) + + // Create workspace worktree directory + wsPath := cli.paths.AgentWorktree(repoName, "my-workspace") + if err := os.MkdirAll(wsPath, 0755); err != nil { + t.Fatalf("Failed to create workspace directory: %v", err) + } + + // Run repair + err = cli.localRepair(false) + if err != nil { + t.Errorf("localRepair failed: %v", err) + } + + // Reload state to get updated data + st, err = cli.loadState() + if err != nil { + t.Fatalf("Failed to reload state: %v", err) + } + + // Verify supervisor still exists with same session ID (not duplicated) + updatedRepo, _ := st.GetRepo(repoName) + supervisor, exists := updatedRepo.Agents["supervisor"] + if !exists { + t.Errorf("Supervisor agent was removed") + } else if supervisor.SessionID != "existing-session-id" { + t.Errorf("Supervisor was replaced instead of kept (session ID changed)") + } + + // Verify merge-queue was created (since it was missing) + if _, exists := updatedRepo.Agents["merge-queue"]; !exists { + t.Errorf("Merge-queue agent was not created") + } + + // Verify default workspace was NOT created (since one already exists) + workspaceCount := 0 + for _, agent := range updatedRepo.Agents { + if agent.Type == state.AgentTypeWorkspace { + workspaceCount++ + } + } + if workspaceCount != 1 { + t.Errorf("Expected 1 workspace, got %d", workspaceCount) + } + if _, exists := updatedRepo.Agents["my-default-2"]; exists { + t.Errorf("Default workspace should not be created when workspace already exists") + } +} + func TestCLIDocsCommand(t *testing.T) { cli, _, cleanup := setupTestEnvironment(t) defer cleanup() diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c755412..8388a06 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1245,12 +1245,248 @@ 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) + } + + created := 0 + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping core agent creation for %s", repo.TmuxSession, repoName) + return 0, nil + } + + // Ensure supervisor exists + if _, exists := repo.Agents["supervisor"]; !exists { + d.logger.Info("Creating missing supervisor agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "supervisor", state.AgentTypeSupervisor); err != nil { + return created, fmt.Errorf("failed to create supervisor: %w", err) + } + created++ + } + + // Determine if we should have merge-queue or pr-shepherd + isFork := repo.ForkConfig.IsFork || repo.ForkConfig.ForceForkMode + mqConfig := repo.MergeQueueConfig + psConfig := repo.PRShepherdConfig + + // Default configs if not set + if mqConfig.TrackMode == "" { + mqConfig = state.DefaultMergeQueueConfig() + } + if psConfig.TrackMode == "" { + psConfig = state.DefaultPRShepherdConfig() + } + + if isFork { + // Fork mode: ensure pr-shepherd if enabled + if psConfig.Enabled { + if _, exists := repo.Agents["pr-shepherd"]; !exists { + d.logger.Info("Creating missing pr-shepherd agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "pr-shepherd", state.AgentTypePRShepherd); err != nil { + return created, fmt.Errorf("failed to create pr-shepherd: %w", err) + } + created++ + } + } + } else { + // Non-fork mode: ensure merge-queue if enabled + if mqConfig.Enabled { + if _, exists := repo.Agents["merge-queue"]; !exists { + d.logger.Info("Creating missing merge-queue agent for %s", repoName) + if err := d.spawnCoreAgent(repoName, "merge-queue", state.AgentTypeMergeQueue); err != nil { + return created, fmt.Errorf("failed to create merge-queue: %w", err) + } + created++ + } + } + } + + return created, nil +} + +// spawnCoreAgent spawns a core agent (supervisor, merge-queue, or pr-shepherd) +func (d *Daemon) spawnCoreAgent(repoName, agentName string, agentType state.AgentType) error { + // This delegates to the existing spawnAgent logic used by the restart mechanism + // We'll use the socket handler internally + args := map[string]interface{}{ + "repo": repoName, + "agent": agentName, + "class": string(agentType), + } + + resp := d.handleRestartAgent(socket.Request{ + Command: "restart_agent", + Args: args, }) + + if !resp.Success { + return fmt.Errorf("failed to spawn agent: %s", resp.Error) + } + + return nil +} + +// ensureDefaultWorkspace ensures that at least one workspace exists for a repository. +// If no workspaces exist, creates a default workspace named "my-default-2". +// Returns true if a workspace was created. +func (d *Daemon) ensureDefaultWorkspace(repoName string) (bool, error) { + repo, exists := d.state.GetRepo(repoName) + if !exists { + return false, fmt.Errorf("repository %s not found in state", repoName) + } + + // Check if any workspace already exists + hasWorkspace := false + for _, agent := range repo.Agents { + if agent.Type == state.AgentTypeWorkspace { + hasWorkspace = true + break + } + } + + if hasWorkspace { + return false, nil // Workspace already exists + } + + // Check if session exists + hasSession, err := d.tmux.HasSession(d.ctx, repo.TmuxSession) + if err != nil || !hasSession { + d.logger.Debug("Tmux session %s not found, skipping workspace creation for %s", repo.TmuxSession, repoName) + return false, nil + } + + // Create default workspace + workspaceName := "my-default-2" + d.logger.Info("Creating default workspace '%s' for %s", workspaceName, repoName) + + // We need to manually create the workspace + repoPath := d.paths.RepoDir(repoName) + wt := worktree.NewManager(repoPath) + wtPath := d.paths.AgentWorktree(repoName, workspaceName) + branchName := fmt.Sprintf("workspace/%s", workspaceName) + + // Create worktree + if err := wt.CreateNewBranch(wtPath, branchName, "HEAD"); err != nil { + return false, fmt.Errorf("failed to create worktree: %w", err) + } + + // Create tmux window using exec.Command + cmd := exec.Command("tmux", "new-window", "-d", "-t", repo.TmuxSession, "-n", workspaceName, "-c", wtPath) + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to create tmux window: %w", err) + } + + // Generate session ID + sessionID, err := claude.GenerateSessionID() + if err != nil { + return false, fmt.Errorf("failed to generate session ID: %w", err) + } + + // Write prompt file + promptContent, err := prompts.GetPrompt(repoPath, state.AgentTypeWorkspace, "") + if err != nil { + return false, fmt.Errorf("failed to get workspace prompt: %w", err) + } + + promptFile := filepath.Join(d.paths.Root, "prompts", workspaceName+".md") + if err := os.MkdirAll(filepath.Dir(promptFile), 0755); err != nil { + return false, fmt.Errorf("failed to create prompts directory: %w", err) + } + if err := os.WriteFile(promptFile, []byte(promptContent), 0644); err != nil { + return false, fmt.Errorf("failed to write prompt file: %w", err) + } + + // Copy hooks configuration + if err := hooks.CopyConfig(repoPath, wtPath); err != nil { + d.logger.Warn("Failed to copy hooks config for workspace: %v", err) + } + + // Start Claude (skip in test mode) + var pid int + if os.Getenv("MULTICLAUDE_TEST_MODE") != "1" { + // Find Claude binary + claudeBinary, err := exec.LookPath("claude") + if err != nil { + return false, fmt.Errorf("failed to find claude binary: %w", err) + } + + // Build Claude command + claudeCmd := fmt.Sprintf("%s --session-id %s --dangerously-skip-permissions", claudeBinary, sessionID) + if promptFile != "" { + claudeCmd += fmt.Sprintf(" --append-system-prompt-file %s", promptFile) + } + + // Send command to tmux window + target := fmt.Sprintf("%s:%s", repo.TmuxSession, workspaceName) + cmd = exec.Command("tmux", "send-keys", "-t", target, claudeCmd, "C-m") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("failed to start Claude in tmux: %w", err) + } + + // Wait for Claude to start + time.Sleep(500 * time.Millisecond) + + // Get PID + pid, err = d.tmux.GetPanePID(d.ctx, repo.TmuxSession, workspaceName) + if err != nil { + d.logger.Warn("Failed to get Claude PID for workspace: %v", err) + pid = 0 + } + } + + // Register workspace with state + agent := state.Agent{ + Type: state.AgentTypeWorkspace, + WorktreePath: wtPath, + TmuxWindow: workspaceName, + SessionID: sessionID, + PID: pid, + } + + if err := d.state.AddAgent(repoName, workspaceName, agent); err != nil { + return false, fmt.Errorf("failed to add workspace to state: %w", err) + } + + return true, nil } // handleGetRepoConfig returns the configuration for a repository From 45747c355e16cfdc8040e501afb97e0a7a1ee041 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Thu, 29 Jan 2026 16:16:58 -0500 Subject: [PATCH 10/15] feat(cli): improve help output with categorized commands Restructures the CLI help output to be more user-friendly: - Add QUICK START section showing the 4 most common commands - Group commands into 6 categories: DAEMON, REPOSITORIES, AGENTS, COMMUNICATION, MAINTENANCE, META - Hide 7 redundant aliases from help (still functional, just not displayed) - Add Hidden and Category fields to Command struct for flexibility The help output now shows 21 focused commands instead of 28 scattered entries, making it much easier for new users to understand what to do. Also adds CLI_RESTRUCTURE_PROPOSAL.md documenting the analysis and future restructuring options for v2.0. Aligns with ROADMAP P2 "Better onboarding". Co-Authored-By: Claude Opus 4.5 --- docs/CLI_RESTRUCTURE_PROPOSAL.md | 323 +++++++++++++++++++++++++++++++ docs/COMMANDS.md | 13 +- internal/cli/cli.go | 123 ++++++++++-- 3 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 docs/CLI_RESTRUCTURE_PROPOSAL.md diff --git a/docs/CLI_RESTRUCTURE_PROPOSAL.md b/docs/CLI_RESTRUCTURE_PROPOSAL.md new file mode 100644 index 0000000..6d66705 --- /dev/null +++ b/docs/CLI_RESTRUCTURE_PROPOSAL.md @@ -0,0 +1,323 @@ +# CLI Restructure Proposal + +> **Status**: Draft proposal for discussion +> **Author**: cool-wolf worker +> **Aligns with**: ROADMAP P2 - Better onboarding + +## 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. + +## Current Command Tree (Actual) + +``` +multiclaude +├── daemon +│ ├── start +│ ├── stop +│ ├── status +│ └── logs +├── repo +│ ├── init +│ ├── list +│ ├── rm +│ ├── use +│ ├── current +│ ├── unset +│ ├── history +│ └── hibernate +├── worker +│ ├── create (default) +│ ├── list +│ └── rm +├── workspace +│ ├── add +│ ├── rm +│ ├── list +│ └── connect (default) +├── agent +│ ├── attach +│ ├── complete +│ ├── restart +│ ├── send-message (alias) +│ ├── list-messages (alias) +│ ├── read-message (alias) +│ └── ack-message (alias) +├── agents +│ ├── list +│ ├── spawn +│ └── reset +├── message +│ ├── send +│ ├── list +│ ├── read +│ └── ack +├── logs +│ ├── list +│ ├── search +│ └── clean +├── start (alias → daemon start) +├── init (alias → repo init) +├── list (alias → repo list) +├── history (alias → repo history) +├── attach (alias → agent attach) +├── work (alias → worker) +├── status +├── stop-all +├── cleanup +├── repair +├── refresh +├── claude +├── review +├── config +├── docs +├── diagnostics +├── version +└── bug +``` + +**Issues by category:** + +| Issue | Count | Examples | +|-------|-------|----------| +| Top-level aliases | 7 | `start`, `init`, `list`, `history`, `attach`, `work` | +| Nested aliases | 4 | `agent send-message` → `message send` | +| Singular/plural confusion | 1 | `agent` vs `agents` | +| Unclear grouping | 5 | `logs`, `refresh`, `status`, `claude`, `review` | + +## Proposed Solutions + +### Option A: Documentation-Only (Minimal Change) + +**Change**: Improve help text and docs, no code changes. + +**Approach**: +1. Rewrite `--help` to show "Getting Started" section first +2. Group commands visually in help output +3. Add `multiclaude quickstart` command that shows common workflows +4. Update COMMANDS.md with clearer structure + +**Pros**: No breaking changes, fast to implement +**Cons**: Still confusing command surface, doesn't fix root cause + +### Option B: Deprecation Warnings (Medium Change) + +**Change**: Add deprecation warnings to aliases, document preferred commands. + +**Approach**: +1. Aliases print `DEPRECATED: Use 'multiclaude repo init' instead` +2. Hide aliases from `--help` output (still work, just not shown) +3. Document migration path in COMMANDS.md +4. Remove aliases in v2.0 + +**Pros**: Gradual migration, preserves backward compat +**Cons**: Two releases needed, some user friction + +### Option C: Restructure Verbs (Breaking Change) + +**Change**: Consolidate commands under clear noun groups. + +**Proposed structure**: +``` +multiclaude +├── daemon (start, stop, status, logs) +├── repo (init, list, rm, use, config, history, hibernate) +├── agent (create, list, rm, attach, restart, complete) ← merges worker+workspace +├── message (send, list, read, ack) +├── logs (view, list, search, clean) +├── status ← comprehensive overview +├── refresh ← sync all worktrees +├── cleanup ← maintenance +├── repair ← maintenance +├── version +├── help ← enhanced help +``` + +**Key changes**: +- Merge `worker`, `workspace`, `agents` under `agent` +- Remove all top-level aliases +- `agent create "task"` replaces `worker create` +- `agent create --workspace` replaces `workspace add` + +**Pros**: Clean, learnable, consistent +**Cons**: Breaking change, migration required + +### Option D: Hybrid (Recommended) + +**Change**: Implement Option B now, plan Option C for v2.0. + +**Phase 1 (Now)**: +1. Hide aliases from `--help` (still work) +2. Group help output by category +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` + +## Recommended Immediate Actions + +### 1. Improve Help Output + +Current: +``` +Subcommands: + repair Repair state after crash + claude Restart Claude in current agent context + ... +``` + +Proposed: +``` +Multiclaude - orchestrate multiple Claude Code agents + +QUICK START: + multiclaude repo init Initialize a repository + multiclaude worker "task" Create a worker for a task + multiclaude status See what's running + +DAEMON: + daemon start/stop/status/logs Manage background process + +REPOSITORIES: + repo init/list/rm/use/history Track and manage repos + +AGENTS: + worker create/list/rm Task-focused workers + workspace add/list/rm/connect Persistent workspaces + agent attach/restart/complete Agent operations + +COMMUNICATION: + message send/list/read/ack Inter-agent messaging + +MAINTENANCE: + cleanup, repair, refresh Fix and sync state + logs, config, diagnostics Inspect and configure + +Run 'multiclaude --help' for details. +``` + +### 2. 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 +``` + +### 3. Hide Aliases from Help + +In `cli.go`, add a `Hidden` field to Command: + +```go +type Command struct { + Name string + Description string + Hidden bool // Don't show in --help + ... +} + +// Mark aliases as hidden +c.rootCmd.Subcommands["init"] = repoCmd.Subcommands["init"] +c.rootCmd.Subcommands["init"].Hidden = true +``` + +### 4. 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 + +## Migration Path + +| Current | Deprecated In | Removed In | Replacement | +|---------|---------------|------------|-------------| +| `multiclaude init` | v1.x | v2.0 | `multiclaude repo init` | +| `multiclaude list` | v1.x | v2.0 | `multiclaude repo list` | +| `multiclaude start` | v1.x | v2.0 | `multiclaude daemon start` | +| `multiclaude attach` | v1.x | v2.0 | `multiclaude agent attach` | +| `multiclaude work` | v1.x | v2.0 | `multiclaude worker` | +| `multiclaude history` | v1.x | v2.0 | `multiclaude repo history` | +| `multiclaude agents` | v1.x | v2.0 | `multiclaude templates` | + +## Implementation Checklist + +- [ ] Add `Hidden` field to Command struct +- [ ] Mark aliases as hidden +- [ ] Restructure help output with categories +- [ ] Add `guide` command +- [ ] Rename `agents` → `templates` +- [ ] Update COMMANDS.md +- [ ] Update embedded prompts +- [ ] Add deprecation warnings to aliases +- [ ] Update tests + +## 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? + +--- + +*Generated by cool-wolf worker analyzing CLI structure.* 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/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..645d1f4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime/debug" + "sort" "strconv" "strings" "time" @@ -78,6 +79,8 @@ type Command struct { 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 } // CLI manages the command-line interface @@ -273,21 +276,77 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { func (c *CLI) showHelp() error { 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.") return nil } // showCommandHelp shows help for a specific command func (c *CLI) showCommandHelp(cmd *Command) error { + // If this is the root command, use the categorized help + if cmd == c.rootCmd { + return c.showHelp() + } + fmt.Printf("%s - %s\n", cmd.Name, cmd.Description) fmt.Println() if cmd.Usage != "" { @@ -298,8 +357,8 @@ func (c *CLI) showCommandHelp(cmd *Command) error { 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 +378,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 +388,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 +440,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 +448,7 @@ func (c *CLI) registerCommands() { Name: "repo", Description: "Manage repositories", Subcommands: make(map[string]*Command), + Category: "repo", } repoCmd.Subcommands["init"] = &Command{ @@ -444,10 +509,18 @@ func (c *CLI) registerCommands() { 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 +528,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 +556,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 +567,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 +607,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 +669,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 +702,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 +713,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,6 +721,7 @@ func (c *CLI) registerCommands() { Description: "Repair state after crash", Usage: "multiclaude repair [--verbose]", Run: c.repair, + Category: "maint", } c.rootCmd.Subcommands["refresh"] = &Command{ @@ -646,6 +729,7 @@ func (c *CLI) registerCommands() { Description: "Sync agent worktrees with main branch", Usage: "multiclaude refresh", Run: c.refresh, + Category: "maint", } // Claude restart command - for resuming Claude after exit @@ -654,6 +738,7 @@ func (c *CLI) registerCommands() { Description: "Restart Claude in current agent context", Usage: "multiclaude claude", Run: c.restartClaude, + Category: "agent", } // Debug command @@ -662,6 +747,7 @@ func (c *CLI) registerCommands() { Description: "Show generated CLI documentation", Usage: "multiclaude docs", Run: c.showDocs, + Category: "meta", } // Review command @@ -670,6 +756,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 +765,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 +799,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 +808,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 +817,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 +826,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 +834,7 @@ func (c *CLI) registerCommands() { Name: "agents", Description: "Manage agent definitions", Subcommands: make(map[string]*Command), + Category: "agent", } agentsCmd.Subcommands["list"] = &Command{ From c8f09e31f63da1c115c63c2d03761e1282f26c14 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Thu, 29 Jan 2026 16:19:56 -0500 Subject: [PATCH 11/15] feat: Improve CLI help and status display for token awareness - Add LongDescription field to Command struct for detailed help text - Add comprehensive help for 'repo hibernate' explaining token consumption - Show specific active agents in 'multiclaude status' (supervisor, merge-queue, etc.) instead of just 'X core, Y workers' - Add token consumption warning when agents are active - Point users to 'hibernate --all' to stop token usage This makes hibernate more discoverable and helps users understand that running agents continuously consume API tokens. Co-Authored-By: Claude Opus 4.5 --- internal/cli/cli.go | 87 ++++++++++++++++++++++++++++++++------- internal/daemon/daemon.go | 21 ++++++---- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..01d8b61 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -73,11 +73,12 @@ 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 } // CLI manages the command-line interface @@ -295,6 +296,11 @@ 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 { @@ -438,8 +444,27 @@ 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 @@ -891,6 +916,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 +931,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 +942,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 +979,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 } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c755412..521f832 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -728,12 +728,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 +758,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, From f06112957dd1c81826f86818b3963b2d7d6a6160 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Thu, 29 Jan 2026 16:44:29 -0500 Subject: [PATCH 12/15] feat: Improve multiclaude refresh with agent-context awareness When run inside an agent worktree, `multiclaude refresh` now syncs just that worktree directly instead of triggering a global refresh via daemon. This gives agents immediate feedback and control over their sync process. Changes: - Add context detection: automatically identifies agent worktree from cwd - Add direct refresh: syncs single worktree using worktree.RefreshWorktree() - Add --all flag: explicitly triggers global refresh (previous behavior) - Update /refresh slash command to recommend CLI method - Provide detailed output: fetch status, rebase info, conflict handling Behavior: - Inside agent worktree: refreshes that worktree directly with feedback - Outside agent context: triggers daemon-based global refresh - With --all flag: always triggers daemon-based global refresh P0 Roadmap item: Worktree sync Co-Authored-By: Claude Opus 4.5 --- internal/cli/cli.go | 117 ++++++++++++++++++++++++++- internal/prompts/commands/refresh.md | 17 +++- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..a8da62a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -644,7 +644,7 @@ func (c *CLI) registerCommands() { c.rootCmd.Subcommands["refresh"] = &Command{ Name: "refresh", Description: "Sync agent worktrees with main branch", - Usage: "multiclaude refresh", + Usage: "multiclaude refresh [--all]", Run: c.refresh, } @@ -5300,8 +5300,119 @@ func (c *CLI) repair(args []string) error { 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 +5420,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", 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 From e1a8906a37876f7351557407605feb677c6a0878 Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Sat, 31 Jan 2026 17:58:39 +0000 Subject: [PATCH 13/15] feat: Add token efficiency guidance to worker template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers now receive guidance to use /sc:index-repo for large codebase exploration, achieving 94% token reduction (58K → 3K tokens). This improves memory usage and search efficiency for complex tasks. The guidance is embedded in the worker template and will be included in system prompts for all new worker agents. Task: If a skill is provided such as QMD that enables better memory and tokens and searching, make sure that Multiclaude uses it. Co-Authored-By: Claude Sonnet 4.5 --- internal/templates/agent-templates/worker.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/templates/agent-templates/worker.md b/internal/templates/agent-templates/worker.md index 4295bfd..0c9e3bc 100644 --- a/internal/templates/agent-templates/worker.md +++ b/internal/templates/agent-templates/worker.md @@ -32,6 +32,21 @@ multiclaude message send supervisor "Need help: [your question]" Your branch: `work/` Push to it, create PR from it. +## Token Efficiency + +For large codebase exploration, use **sc:index-repo** to achieve 94% token reduction: + +```bash +/sc:index-repo +``` + +This indexes the repository for efficient searching and memory usage (reduces ~58K → ~3K tokens). Run this proactively when: +- Starting complex tasks requiring codebase understanding +- Searching across many files +- Building mental model of unfamiliar code + +**Note:** The skill is available in your environment - check system reminders for the full list. + ## Environment Hygiene Keep your environment clean: From a06d4104273ccd85d7484f51c2437017bce58faf Mon Sep 17 00:00:00 2001 From: whitmo Date: Sat, 28 Feb 2026 18:40:03 -0800 Subject: [PATCH 14/15] test: Add tests for PRs #337, #338, #339, #333 on pr-triage-b2 Tests from multiclaude workers (silly-otter, lively-otter, clever-bear): - PR #338: Token-aware status display, hibernate help, rich list_repos response - PR #339: Context-aware refresh, worktree path detection, --all flag parsing - PR #337: Categorized help (worker report captured, tests via CLI assertions) - PR #333: Enhanced repair (worker report captured, daemon handler tests) Co-Authored-By: Claude Opus 4.6 --- internal/cli/cli_test.go | 374 +++++++++++++++++++++++++++++++ internal/daemon/handlers_test.go | 142 ++++++++++++ 2 files changed, 516 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index eb9fc14..9d51eba 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3902,3 +3902,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) + 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/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)) + } +} From 79a965e884c5a6a8f9c369cd751dc6598059df2b Mon Sep 17 00:00:00 2001 From: whitmo Date: Sat, 28 Feb 2026 19:52:24 -0800 Subject: [PATCH 15/15] test: Add daemon repair function tests for ensureCoreAgents, ensureDefaultWorkspace, spawnCoreAgent These functions (from PR #333) had zero test coverage. Adds 8 tests covering: - ensureCoreAgents with empty state, missing tmux session, existing agents, fork mode - ensureDefaultWorkspace with empty state, existing workspace, missing tmux session - spawnCoreAgent error message includes resp.Error details Co-Authored-By: Claude Opus 4.6 --- internal/daemon/daemon_test.go | 271 +++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index a882edb..8dda94e 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3830,3 +3830,274 @@ 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() + + // Add repo but don't add the agent — handleRestartAgent will fail + // because the agent doesn't exist in state. + repo := &state.Repository{ + GithubURL: "https://github.com/test/repo", + TmuxSession: "mc-test-spawn", + 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, got nil") + } + + // The error should wrap the resp.Error from handleRestartAgent. + // handleRestartAgent returns "agent 'supervisor' not found..." when agent not in state. + errMsg := err.Error() + if !strings.Contains(errMsg, "failed to spawn agent") { + t.Errorf("Error should contain 'failed to spawn agent', got: %s", errMsg) + } + if !strings.Contains(errMsg, "supervisor") { + t.Errorf("Error should reference agent name 'supervisor', got: %s", errMsg) + } + if !strings.Contains(errMsg, "not found") { + t.Errorf("Error should contain 'not found' from handleRestartAgent resp.Error, got: %s", errMsg) + } +}