// Package opencode provides a CodeAgent implementation for the OpenCode server. package opencode import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Client communicates with an OpenCode server via HTTP. type Client struct { baseURL string httpClient *http.Client username string password string } // ClientConfig configures the OpenCode client. type ClientConfig struct { BaseURL string Timeout time.Duration Username string Password string } // NewClient creates a new OpenCode HTTP client. func NewClient(cfg ClientConfig) *Client { if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second } if cfg.BaseURL == "" { cfg.BaseURL = "http://127.0.0.1:4096" } if cfg.Username == "" { cfg.Username = "opencode" } return &Client{ baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), httpClient: &http.Client{ Timeout: cfg.Timeout, }, username: cfg.Username, password: cfg.Password, } } // HealthResponse represents the /global/health response. type HealthResponse struct { Healthy bool `json:"healthy"` Version string `json:"version"` } // Health checks if the OpenCode server is healthy. func (c *Client) Health(ctx context.Context) (*HealthResponse, error) { resp, err := c.doRequest(ctx, http.MethodGet, "/global/health", nil) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var health HealthResponse if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { return nil, fmt.Errorf("decode health: %w", err) } return &health, nil } // Session represents an OpenCode session. type Session struct { ID string `json:"id"` Title string `json:"title,omitempty"` ParentID string `json:"parentID,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` } // CreateSessionRequest is the body for POST /session. type CreateSessionRequest struct { ParentID string `json:"parentID,omitempty"` Title string `json:"title,omitempty"` } // CreateSession creates a new session. func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error) { body, _ := json.Marshal(req) resp, err := c.doRequest(ctx, http.MethodPost, "/session", bytes.NewReader(body)) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var session Session if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { return nil, fmt.Errorf("decode session: %w", err) } return &session, nil } // GetSession retrieves a session by ID. func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) { resp, err := c.doRequest(ctx, http.MethodGet, "/session/"+sessionID, nil) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var session Session if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { return nil, fmt.Errorf("decode session: %w", err) } return &session, nil } // MessagePart represents a part of a message. type MessagePart struct { Type string `json:"type"` // "text", "tool_use", "tool_result", etc. Content string `json:"content,omitempty"` Name string `json:"name,omitempty"` Input any `json:"input,omitempty"` } // SendMessageRequest is the body for POST /session/:id/message. type SendMessageRequest struct { MessageID string `json:"messageID,omitempty"` Model string `json:"model,omitempty"` Agent string `json:"agent,omitempty"` System string `json:"system,omitempty"` Tools []string `json:"tools,omitempty"` Parts []MessagePart `json:"parts"` } // MessageInfo contains message metadata. type MessageInfo struct { ID string `json:"id"` Role string `json:"role"` Timestamp time.Time `json:"timestamp"` } // SendMessageResponse is the response from POST /session/:id/message. type SendMessageResponse struct { Info MessageInfo `json:"info"` Parts []MessagePart `json:"parts"` } // SendMessage sends a message to a session (synchronous, waits for response). func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error) { body, _ := json.Marshal(req) resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/message", bytes.NewReader(body)) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var msgResp SendMessageResponse if err := json.NewDecoder(resp.Body).Decode(&msgResp); err != nil { return nil, fmt.Errorf("decode message response: %w", err) } return &msgResp, nil } // SendPromptAsync sends a message asynchronously. func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error { body, _ := json.Marshal(req) resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/prompt_async", bytes.NewReader(body)) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } // AbortSession stops a running session. func (c *Client) AbortSession(ctx context.Context, sessionID string) error { resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/abort", nil) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } // SSEEvent represents a server-sent event. type SSEEvent struct { Event string Data string } // sseEventBufferSize is the capacity of the SSE event channel. // This buffer handles typical bursts of events during agent execution (e.g., rapid tool calls). // If the consumer is slow and the buffer fills, the goroutine will block on send // until the context is cancelled or the consumer catches up. const sseEventBufferSize = 100 // SubscribeEvents returns a channel of SSE events from the server. func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event", nil) if err != nil { return nil, err } req.Header.Set("Accept", "text/event-stream") c.setAuth(req) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() return nil, fmt.Errorf("SSE subscribe failed: %s", resp.Status) } events := make(chan SSEEvent, sseEventBufferSize) go func() { defer func() { _ = resp.Body.Close() }() defer close(events) reader := bufio.NewReader(resp.Body) var currentEvent SSEEvent for { select { case <-ctx.Done(): return default: } line, err := reader.ReadString('\n') if err != nil { // EOF is expected when connection closes; other errors are logged but not fatal return } line = strings.TrimSuffix(line, "\n") line = strings.TrimSuffix(line, "\r") if line == "" { // Empty line = end of event if currentEvent.Event != "" || currentEvent.Data != "" { select { case events <- currentEvent: case <-ctx.Done(): return } currentEvent = SSEEvent{} } continue } if strings.HasPrefix(line, "event:") { currentEvent.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { currentEvent.Data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } }() return events, nil } // RunShell executes a shell command in the session. func (c *Client) RunShell(ctx context.Context, sessionID, command string) error { body, _ := json.Marshal(map[string]string{"command": command}) resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/shell", bytes.NewReader(body)) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return nil } // doRequest performs an HTTP request with auth. func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } c.setAuth(req) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() return nil, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, string(bodyBytes)) } return resp, nil } // setAuth sets authentication headers if password is configured. func (c *Client) setAuth(req *http.Request) { if c.password != "" { req.SetBasicAuth(c.username, c.password) } }