287 lines
7.8 KiB
Go
287 lines
7.8 KiB
Go
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 == "<nil>" {
|
|
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 ""
|
|
}
|