package opencodeadapter import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os/exec" "strings" "sync" "time" "xworkmate-bridge/internal/shared" ) type opencodeHTTPClient struct { mu sync.Mutex command string args []string cwd string cmd *exec.Cmd baseURL string client *http.Client } func newOpenCodeHTTPClient(command string, args []string) *opencodeHTTPClient { return &opencodeHTTPClient{ command: strings.TrimSpace(command), args: append([]string(nil), args...), baseURL: "http://127.0.0.1:38993", client: shared.NewHTTPClient(5 * time.Minute), } } func (c *opencodeHTTPClient) Initialize() (initializeResult, error) { if err := c.ensureStarted(); err != nil { return initializeResult{}, err } req, err := http.NewRequest(http.MethodGet, c.baseURL+"/global/health", nil) if err != nil { return initializeResult{}, err } resp, err := c.client.Do(req) if err != nil { return initializeResult{}, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return initializeResult{}, fmt.Errorf("opencode health failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) } return initializeResult{ ProtocolVersion: 1, AuthMethods: []map[string]any{ {"id": "opencode-login", "name": "Login with opencode", "description": "Run `opencode auth login` in the terminal"}, }, AgentCapabilities: map[string]any{ "loadSession": true, "mcpCapabilities": map[string]any{"http": true, "sse": true}, "promptCapabilities": map[string]any{"embeddedContext": true, "image": true}, "sessionCapabilities": map[string]any{"fork": map[string]any{}, "list": map[string]any{}, "resume": map[string]any{}}, }, }, nil } func (c *opencodeHTTPClient) Call(method string, params map[string]any) (map[string]any, error) { if err := c.ensureStarted(); err != nil { return nil, err } switch strings.TrimSpace(method) { case "session.start", "session.message": sessionID := strings.TrimSpace(fmt.Sprint(params["sessionId"])) prompt := strings.TrimSpace(sharedStringArg(params, "taskPrompt", "")) return c.postSessionMessage(sessionID, prompt, params) case "session.cancel": return map[string]any{"accepted": true, "cancelled": false}, nil case "session.close": return map[string]any{"accepted": true, "closed": true}, nil default: return nil, fmt.Errorf("unsupported opencode method: %s", method) } } func sharedStringArg(params map[string]any, key, fallback string) string { return shared.StringArg(params, key, fallback) } func (c *opencodeHTTPClient) CreateSession(title string) (string, error) { if err := c.ensureStarted(); err != nil { return "", err } body := map[string]any{} if strings.TrimSpace(title) != "" { body["title"] = strings.TrimSpace(title) } encoded, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, c.baseURL+"/session", bytes.NewReader(encoded)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("opencode create session failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var decoded map[string]any if err := json.Unmarshal(raw, &decoded); err != nil { return "", fmt.Errorf("decode opencode create session response: %w", err) } if sessionID := extractOpenCodeSessionID(decoded); sessionID != "" { return sessionID, nil } if sessionID := extractOpenCodeSessionID(shared.AsMap(decoded["result"])); sessionID != "" { return sessionID, nil } return "", fmt.Errorf("opencode create session returned no session id") } func (c *opencodeHTTPClient) SendMessage(sessionID, prompt string, params map[string]any) (map[string]any, error) { if err := c.ensureStarted(); err != nil { return nil, err } return c.postSessionMessage(sessionID, prompt, params) } func (c *opencodeHTTPClient) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.cmd != nil && c.cmd.Process != nil { _ = c.cmd.Process.Kill() _, _ = c.cmd.Process.Wait() } c.cmd = nil return nil } func (c *opencodeHTTPClient) ensureStarted() error { c.mu.Lock() defer c.mu.Unlock() if c.cmd != nil { return nil } if c.command == "" { return fmt.Errorf("opencode command is empty") } cmd := exec.Command(c.command, c.args...) if strings.TrimSpace(c.cwd) != "" { cmd.Dir = strings.TrimSpace(c.cwd) } if err := cmd.Start(); err != nil { return err } c.cmd = cmd return c.waitReady() } func (c *opencodeHTTPClient) waitReady() error { deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/global/health", nil) resp, err := c.client.Do(req) if err == nil && resp != nil { _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { return nil } } time.Sleep(300 * time.Millisecond) } return fmt.Errorf("opencode server did not become ready") } func (c *opencodeHTTPClient) postSessionMessage(sessionID, prompt string, params map[string]any) (map[string]any, error) { if sessionID == "" { return nil, fmt.Errorf("sessionId is required") } body := map[string]any{ "parts": []map[string]any{ {"type": "text", "text": strings.TrimSpace(prompt)}, }, } if model := strings.TrimSpace(fmt.Sprint(params["model"])); model != "" { body["model"] = model } if agent := strings.TrimSpace(fmt.Sprint(params["agent"])); agent != "" { body["agent"] = agent } if system := strings.TrimSpace(fmt.Sprint(params["system"])); system != "" { body["system"] = system } encoded, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, c.baseURL+"/session/"+sessionID+"/message", bytes.NewReader(encoded)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("opencode session message failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var decoded map[string]any if err := json.Unmarshal(raw, &decoded); err != nil { return nil, fmt.Errorf("decode opencode response: %w", err) } text := extractOpenCodeText(decoded) result := map[string]any{ "success": true, "provider": "opencode", "mode": "single-agent", "sessionId": sessionID, "output": text, "summary": text, "message": text, } return result, nil } func extractOpenCodeSessionID(value any) string { switch v := value.(type) { case string: return sanitizeOpenCodeProviderSessionID(v) case map[string]any: for _, key := range []string{"sessionId", "sessionID", "session_id", "id"} { if sessionID := sanitizeOpenCodeProviderSessionID(shared.StringArg(v, key, "")); sessionID != "" { return sessionID } } } return "" } func sanitizeOpenCodeProviderSessionID(raw string) string { sessionID := strings.TrimSpace(raw) if sessionID == "" || sessionID == "" { return "" } return sessionID } func extractOpenCodeText(value any) string { switch v := value.(type) { case string: return strings.TrimSpace(v) case map[string]any: for _, key := range []string{"text", "message", "output", "content", "summary"} { if text := extractOpenCodeText(v[key]); text != "" { return text } } for _, child := range v { if text := extractOpenCodeText(child); text != "" { return text } } case []any: for _, child := range v { if text := extractOpenCodeText(child); text != "" { return text } } } return "" }