|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "os/signal" |
| 8 | + "syscall" |
| 9 | + |
| 10 | + "github.com/spf13/cobra" |
| 11 | + |
| 12 | + "gptcode/internal/acp" |
| 13 | + "gptcode/internal/config" |
| 14 | + "gptcode/internal/llm" |
| 15 | + "gptcode/internal/maestro" |
| 16 | + "gptcode/internal/tools" |
| 17 | +) |
| 18 | + |
| 19 | +var acpCmd = &cobra.Command{ |
| 20 | + Use: "acp", |
| 21 | + Short: "Run as an ACP (Agent Client Protocol) agent", |
| 22 | + Long: `Start GPTCode as an ACP-compliant agent, communicating via JSON-RPC 2.0 over stdio. |
| 23 | +
|
| 24 | +This allows any ACP-compatible editor (Zed, JetBrains, Neovim, VS Code) to use |
| 25 | +GPTCode as their AI coding agent. The editor launches this command as a subprocess |
| 26 | +and communicates via stdin/stdout. |
| 27 | +
|
| 28 | +For more information, see: https://agentclientprotocol.com`, |
| 29 | + RunE: runACP, |
| 30 | +} |
| 31 | + |
| 32 | +func init() { |
| 33 | + rootCmd.AddCommand(acpCmd) |
| 34 | +} |
| 35 | + |
| 36 | +func runACP(cmd *cobra.Command, args []string) error { |
| 37 | + // Create context with signal handling |
| 38 | + ctx, cancel := context.WithCancel(context.Background()) |
| 39 | + defer cancel() |
| 40 | + |
| 41 | + sigCh := make(chan os.Signal, 1) |
| 42 | + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) |
| 43 | + go func() { |
| 44 | + <-sigCh |
| 45 | + fmt.Fprintln(os.Stderr, "[ACP] Signal received, shutting down") |
| 46 | + cancel() |
| 47 | + }() |
| 48 | + |
| 49 | + // Create the session handler that bridges ACP to our Maestro engine |
| 50 | + handler := &acpSessionHandler{} |
| 51 | + |
| 52 | + // Create and run the ACP server |
| 53 | + server := acp.NewServer(handler) |
| 54 | + handler.server = server |
| 55 | + |
| 56 | + return server.Run(ctx) |
| 57 | +} |
| 58 | + |
| 59 | +// acpSessionHandler bridges ACP sessions to the GPTCode Maestro engine. |
| 60 | +type acpSessionHandler struct { |
| 61 | + server *acp.Server |
| 62 | +} |
| 63 | + |
| 64 | +func (h *acpSessionHandler) HandlePrompt(ctx context.Context, sessionID string, content []acp.ContentBlock, emitter acp.UpdateEmitter) (acp.SessionPromptResult, error) { |
| 65 | + // Extract the text prompt |
| 66 | + var prompt string |
| 67 | + for _, block := range content { |
| 68 | + if block.Type == "text" { |
| 69 | + if prompt != "" { |
| 70 | + prompt += "\n" |
| 71 | + } |
| 72 | + prompt += block.Text |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + if prompt == "" { |
| 77 | + return acp.SessionPromptResult{StopReason: "endTurn"}, nil |
| 78 | + } |
| 79 | + |
| 80 | + // Get session info for working directory |
| 81 | + session := h.server.GetSession(sessionID) |
| 82 | + cwd := "." |
| 83 | + if session != nil && session.WorkingDirectory != "" { |
| 84 | + cwd = session.WorkingDirectory |
| 85 | + } |
| 86 | + |
| 87 | + // Load setup config |
| 88 | + setup, err := config.LoadSetup() |
| 89 | + if err != nil { |
| 90 | + fmt.Fprintf(os.Stderr, "[ACP] Failed to load setup: %v, using defaults\n", err) |
| 91 | + setup = &config.Setup{ |
| 92 | + Backend: make(map[string]config.BackendConfig), |
| 93 | + } |
| 94 | + setup.Defaults.Backend = "openrouter" |
| 95 | + } |
| 96 | + |
| 97 | + // Determine mode from session config |
| 98 | + mode := "edit" |
| 99 | + if session != nil && session.ConfigOptions != nil { |
| 100 | + if m, ok := session.ConfigOptions["mode"]; ok { |
| 101 | + mode = m |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + // Create model selector |
| 106 | + selector, err := config.NewModelSelector(setup) |
| 107 | + if err != nil { |
| 108 | + return acp.SessionPromptResult{}, fmt.Errorf("failed to create model selector: %w", err) |
| 109 | + } |
| 110 | + |
| 111 | + // Determine language from session or default |
| 112 | + language := setup.Defaults.Lang |
| 113 | + if language == "" { |
| 114 | + language = "go" |
| 115 | + } |
| 116 | + |
| 117 | + // Create the Maestro conductor |
| 118 | + conductor := maestro.NewConductor(selector, setup, cwd, language) |
| 119 | + |
| 120 | + // Create the ACP tool bridge for delegating to editor |
| 121 | + bridge := acp.NewToolsBridge(h.server, cwd) |
| 122 | + |
| 123 | + // Emit plan at start |
| 124 | + emitter.EmitPlan("Processing prompt", []acp.PlanStep{ |
| 125 | + {Title: "Analyzing task", Status: "running"}, |
| 126 | + {Title: "Planning solution", Status: "pending"}, |
| 127 | + {Title: "Implementing changes", Status: "pending"}, |
| 128 | + }) |
| 129 | + |
| 130 | + // For simple queries (mode=query/research), use direct LLM call |
| 131 | + if mode == "query" || mode == "research" { |
| 132 | + emitter.EmitPlan("Research", []acp.PlanStep{ |
| 133 | + {Title: "Researching", Status: "running"}, |
| 134 | + }) |
| 135 | + |
| 136 | + backendName := setup.Defaults.Backend |
| 137 | + if backendName == "" { |
| 138 | + backendName = "openrouter" |
| 139 | + } |
| 140 | + baseURL := "https://openrouter.ai/api/v1" |
| 141 | + if bc, ok := setup.Backend[backendName]; ok && bc.BaseURL != "" { |
| 142 | + baseURL = bc.BaseURL |
| 143 | + } |
| 144 | + |
| 145 | + provider := llm.NewChatCompletion(baseURL, backendName) |
| 146 | + model := setup.Defaults.Model |
| 147 | + if model == "" { |
| 148 | + model = "anthropic/claude-sonnet-4" |
| 149 | + } |
| 150 | + |
| 151 | + resp, err := provider.Chat(ctx, llm.ChatRequest{ |
| 152 | + SystemPrompt: "You are GPTCode, an expert coding assistant.", |
| 153 | + Model: model, |
| 154 | + UserPrompt: prompt, |
| 155 | + }) |
| 156 | + if err != nil { |
| 157 | + return acp.SessionPromptResult{}, err |
| 158 | + } |
| 159 | + |
| 160 | + emitter.EmitText(resp.Text) |
| 161 | + return acp.SessionPromptResult{StopReason: "endTurn"}, nil |
| 162 | + } |
| 163 | + |
| 164 | + // For edit/plan/review modes, use the full Maestro pipeline |
| 165 | + emitter.EmitPlan("Executing task", []acp.PlanStep{ |
| 166 | + {Title: "Analyzing task", Status: "completed"}, |
| 167 | + {Title: "Planning solution", Status: "running"}, |
| 168 | + {Title: "Implementing changes", Status: "pending"}, |
| 169 | + }) |
| 170 | + |
| 171 | + // Execute via conductor — the bridge will delegate tools to the editor |
| 172 | + _ = bridge // Bridge will be used when we wire it into the conductor's tool executor |
| 173 | + err = conductor.ExecuteTask(ctx, prompt, "complex") |
| 174 | + if err != nil { |
| 175 | + emitter.EmitPlan("Task failed", []acp.PlanStep{ |
| 176 | + {Title: "Analyzing task", Status: "completed"}, |
| 177 | + {Title: "Planning solution", Status: "error"}, |
| 178 | + }) |
| 179 | + return acp.SessionPromptResult{}, err |
| 180 | + } |
| 181 | + |
| 182 | + emitter.EmitPlan("Task completed", []acp.PlanStep{ |
| 183 | + {Title: "Analyzing task", Status: "completed"}, |
| 184 | + {Title: "Planning solution", Status: "completed"}, |
| 185 | + {Title: "Implementing changes", Status: "completed"}, |
| 186 | + }) |
| 187 | + |
| 188 | + // Get the list of modified files |
| 189 | + emitter.EmitText("\n\n✅ Task completed successfully.") |
| 190 | + |
| 191 | + return acp.SessionPromptResult{StopReason: "endTurn"}, nil |
| 192 | +} |
| 193 | + |
| 194 | +// Ensure tools package is referenced (used by bridge) |
| 195 | +var _ = tools.ExecuteTool |
0 commit comments