iop/apps/node/internal/adapters/cli/opencode_sse.go
toki f01c7e5ecd refactor(node): CLI 실행 경계를 정리한다
터미널 세션 core와 CLI mode executor 경계를 분리해 후속 remote terminal bridge가 재사용할 내부 실행 기반을 만든다.

adapter 기본값과 vLLM experimental surface도 명시해 production 실행 경로가 mock fallback이나 미구현 실행으로 숨지 않게 한다.
2026-06-06 14:57:49 +09:00

843 lines
23 KiB
Go

package cli
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
// opencodeSSESession tracks a logical session backed by an OpenCode server.
// When owned is true, the local server process is started by the adapter and
// must be killed on close. When false, the adapter attaches to an external
// server via --attach and never owns the process lifecycle.
type opencodeSSESession struct {
key sessionKey
serverURL string
sessionID string
cmd *exec.Cmd
owned bool
mu sync.Mutex
}
// opencodeRunOpts is the subset of opencode CLI options that affect SSE mode.
type opencodeRunOpts struct {
AttachURL string
Model string
Agent string
Variant string
Title string
Dir string
DangerouslySkipPermissions bool
}
func parseOpencodeRunArgs(args []string) opencodeRunOpts {
var opts opencodeRunOpts
for i := 0; i < len(args); i++ {
a := args[i]
next := func() (string, bool) {
if i+1 < len(args) {
v := args[i+1]
i++
return v, true
}
return "", false
}
switch a {
case "--attach":
if v, ok := next(); ok {
opts.AttachURL = v
}
case "--model":
if v, ok := next(); ok {
opts.Model = v
}
case "--agent":
if v, ok := next(); ok {
opts.Agent = v
}
case "--variant":
if v, ok := next(); ok {
opts.Variant = v
}
case "--title":
if v, ok := next(); ok {
opts.Title = v
}
case "--dir":
if v, ok := next(); ok {
opts.Dir = v
}
case "--dangerously-skip-permissions":
opts.DangerouslySkipPermissions = true
}
}
return opts
}
// decodeSSEDataLine returns the JSON payload of an SSE "data:" line, or
// (nil, false) if the line is not a usable data line.
func decodeSSEDataLine(line []byte) ([]byte, bool) {
if !bytes.HasPrefix(line, []byte("data:")) {
return nil, false
}
data := bytes.TrimSpace(line[len("data:"):])
if len(data) == 0 {
return nil, false
}
return data, true
}
func (e *opencodeExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
opts := parseOpencodeRunArgs(profile.Args)
sess, err := e.resolveOpencodeSession(ctx, spec, profile, opts)
if err != nil {
return err
}
sess.mu.Lock()
defer sess.mu.Unlock()
// Open SSE stream before sending the prompt so early events are not missed.
// OpenCode 1.14.x publishes session events through /global/event as a
// top-level payload wrapper; /event only reports server connection events.
sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, sess.serverURL+"/global/event", nil)
if err != nil {
return fmt.Errorf("cli adapter: opencode sse new request: %w", err)
}
sseReq.Header.Set("Accept", "text/event-stream")
sseResp, err := http.DefaultClient.Do(sseReq)
if err != nil {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("opencode sse connect: %v", err),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: opencode sse connect: %w", err)
}
defer sseResp.Body.Close()
if sseResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(sseResp.Body, 1024))
msg := fmt.Sprintf("opencode sse status %d: %s", sseResp.StatusCode, strings.TrimSpace(string(body)))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: %s", msg)
}
if sess.sessionID == "" {
id, err := opencodeCreateSession(ctx, sess.serverURL, opts.Title)
if err != nil {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("opencode create session: %v", err),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: opencode create session: %w", err)
}
sess.sessionID = id
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
prompt := extractPrompt(spec.Input)
if err := opencodePromptAsync(ctx, sess.serverURL, sess.sessionID, prompt, opts); err != nil {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("opencode prompt async: %v", err),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: opencode prompt async: %w", err)
}
return e.driveOpencodeSSE(ctx, spec, sess, opts, sseResp.Body, sink)
}
func (e *opencodeExecutor) resolveOpencodeSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, opts opencodeRunOpts) (*opencodeSSESession, error) {
target := cliTargetName(spec)
key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)}
e.mu.Lock()
if sess, ok := e.sessions[key]; ok {
e.mu.Unlock()
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
e.mu.Unlock()
return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID)
}
e.mu.Unlock()
serverURL := strings.TrimRight(opts.AttachURL, "/")
var cmd *exec.Cmd
owned := false
if serverURL == "" {
url, started, err := startOpencodeLocalServer(ctx, profile, e.cli.logger)
if err != nil {
return nil, fmt.Errorf("cli adapter: start opencode server: %w", err)
}
serverURL = url
cmd = started
owned = true
}
sess := &opencodeSSESession{
key: key,
serverURL: serverURL,
cmd: cmd,
owned: owned,
}
e.mu.Lock()
if existing, ok := e.sessions[key]; ok {
e.mu.Unlock()
if owned && cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
return existing, nil
}
e.sessions[key] = sess
e.mu.Unlock()
return sess, nil
}
func (e *opencodeExecutor) Sessions() []sessionListEntry {
e.mu.Lock()
defer e.mu.Unlock()
snaps := make([]sessionListEntry, 0, len(e.sessions))
for k := range e.sessions {
snaps = append(snaps, sessionListEntry{"opencode-sse", k.target, k.sessionID})
}
return snaps
}
func (e *opencodeExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) {
key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)}
e.mu.Lock()
sess, ok := e.sessions[key]
if ok {
delete(e.sessions, key)
}
e.mu.Unlock()
if !ok {
return false, nil
}
closeOpencodeSession(sess)
return true, nil
}
func (e *opencodeExecutor) Stop(ctx context.Context) error {
e.mu.Lock()
opencodeCopy := make(map[sessionKey]*opencodeSSESession, len(e.sessions))
for key, sess := range e.sessions {
opencodeCopy[key] = sess
}
e.sessions = make(map[sessionKey]*opencodeSSESession)
e.mu.Unlock()
for _, sess := range opencodeCopy {
closeOpencodeSession(sess)
}
return nil
}
var opencodeServerListenRE = regexp.MustCompile(`(?i)opencode server listening on (https?://\S+)`)
func startOpencodeLocalServer(ctx context.Context, profile config.CLIProfileConf, logger *zap.Logger) (string, *exec.Cmd, error) {
args := []string{"serve", "--hostname", "127.0.0.1", "--port", "0"}
cmd := exec.Command(profile.Command, args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", nil, fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("start opencode serve: %w", err)
}
urlCh := make(chan string, 2)
scan := func(r io.Reader, name string) {
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 64*1024), 1024*1024)
for s.Scan() {
line := s.Text()
if logger != nil {
logger.Debug("opencode server log", zap.String("stream", name), zap.String("line", line))
}
if m := opencodeServerListenRE.FindStringSubmatch(line); len(m) >= 2 {
select {
case urlCh <- m[1]:
default:
}
}
}
}
go scan(stdout, "stdout")
go scan(stderr, "stderr")
select {
case u := <-urlCh:
return strings.TrimRight(u, "/"), cmd, nil
case <-time.After(30 * time.Second):
_ = cmd.Process.Kill()
return "", nil, errors.New("timeout waiting for opencode server listen line")
case <-ctx.Done():
_ = cmd.Process.Kill()
return "", nil, ctx.Err()
}
}
type opencodeEnvelope struct {
Type string `json:"type"`
Properties map[string]any `json:"properties"`
}
type opencodeGlobalEnvelope struct {
Payload *opencodeEnvelope `json:"payload"`
}
func opencodeEventEnvelope(data []byte) (opencodeEnvelope, bool) {
var env opencodeEnvelope
if err := json.Unmarshal(data, &env); err == nil && env.Type != "" && env.Type != "sync" {
return env, true
}
var global opencodeGlobalEnvelope
if err := json.Unmarshal(data, &global); err != nil || global.Payload == nil || global.Payload.Type == "" || global.Payload.Type == "sync" {
return opencodeEnvelope{}, false
}
return *global.Payload, true
}
// sseEvtTrace summarises one received SSE event for timeout/cancel diagnostics.
type sseEvtTrace struct {
Type string `json:"t"`
SID string `json:"sid,omitempty"` // "ok", "mm" (mismatch), or ""
Outcome string `json:"out,omitempty"` // "delta", "idle", "error", "skip", "filtered"
}
func (e *opencodeExecutor) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error {
type evt struct {
ev opencodeEnvelope
raw []byte
}
events := make(chan evt, 32)
scanErrCh := make(chan error, 1)
go func() {
defer close(events)
s := bufio.NewScanner(body)
s.Buffer(make([]byte, 64*1024), 4*1024*1024)
for s.Scan() {
line := s.Bytes()
data, ok := decodeSSEDataLine(line)
if !ok {
continue
}
env, ok := opencodeEventEnvelope(data)
if !ok {
continue
}
cp := make([]byte, len(data))
copy(cp, data)
events <- evt{ev: env, raw: cp}
}
scanErrCh <- s.Err()
}()
var (
inputTokens int
outputTokens int
partTypes = map[string]string{}
)
// Bounded ring buffer for cancel/timeout diagnostics.
const traceSize = 8
var (
traceRing [traceSize]sseEvtTrace
traceHead int
traceSeen int
)
addTrace := func(entry sseEvtTrace) {
traceRing[traceHead%traceSize] = entry
traceHead++
traceSeen++
}
recentTrace := func() []sseEvtTrace {
n := traceHead
if n > traceSize {
n = traceSize
}
out := make([]sseEvtTrace, n)
start := traceHead - n
for i := 0; i < n; i++ {
out[i] = traceRing[(start+i)%traceSize]
}
return out
}
finalize := func(reason error) error {
bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if sess.sessionID != "" {
_ = opencodeAbort(bg, sess.serverURL, sess.sessionID)
}
if e.cli.logger != nil {
e.cli.logger.Warn("opencode sse run cancelled without completion",
zap.String("run_id", spec.RunID),
zap.String("reason", reason.Error()),
zap.Int("total_events", traceSeen),
zap.Any("recent_events", recentTrace()),
)
}
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeCancelled,
Message: cancelEventForContext(reason),
Timestamp: time.Now(),
})
return runtime.ErrRunCancelled
}
for {
select {
case <-ctx.Done():
return finalize(ctx.Err())
case e, ok := <-events:
if !ok {
if ctx.Err() != nil {
return finalize(ctx.Err())
}
if err := <-scanErrCh; err != nil && !errors.Is(err, io.EOF) {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("opencode sse scan: %v", err),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: opencode sse scan: %w", err)
}
return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens)
}
// Filter events that carry a sessionID mismatching ours, when present.
if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" && sess.sessionID != "" && sid != sess.sessionID {
addTrace(sseEvtTrace{Type: e.ev.Type, SID: "mm", Outcome: "filtered"})
continue
}
sidStatus := ""
if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" {
sidStatus = "ok"
}
switch e.ev.Type {
case "session.next.text.delta":
delta := opencodeDeltaText(e.ev.Properties)
if delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"})
} else {
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
}
case "message.part.updated":
if partID, partType, ok := opencodePartInfo(e.ev.Properties); ok {
partTypes[partID] = partType
}
if in, out, ok := opencodeStepTokens(e.ev.Properties); ok {
if in > 0 {
inputTokens = in
}
if out > 0 {
outputTokens = out
}
}
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
case "message.part.delta":
delta := opencodeMessagePartDeltaText(e.ev.Properties, partTypes)
if delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"})
} else {
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
}
case "session.next.step.ended":
if in, out, ok := opencodeStepTokens(e.ev.Properties); ok {
if in > 0 {
inputTokens = in
}
if out > 0 {
outputTokens = out
}
}
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
case "session.idle":
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"})
return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens)
case "session.status":
if opencodeStatusIdle(e.ev.Properties) {
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"})
return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens)
}
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
case "session.error":
msg := opencodeErrorMessage(e.ev.Properties)
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "error"})
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: opencode session error: %s", msg)
case "permission.asked":
permID, _ := stringFromProp(e.ev.Properties, "id")
if permID == "" {
permID, _ = stringFromProp(e.ev.Properties, "permissionID")
}
if permID != "" {
reply := "reject"
if opts.DangerouslySkipPermissions {
reply = "once"
}
bg, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = opencodePermissionReply(bg, sess.serverURL, permID, reply)
cancel()
}
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
default:
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
}
}
}
}
func stringFromProp(props map[string]any, key string) (string, bool) {
if props == nil {
return "", false
}
v, ok := props[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
func opencodeDeltaText(props map[string]any) string {
if props == nil {
return ""
}
if s, ok := props["delta"].(string); ok && s != "" {
return s
}
if part, ok := props["part"].(map[string]any); ok {
if s, ok := part["text"].(string); ok {
return s
}
}
return ""
}
func opencodePartInfo(props map[string]any) (string, string, bool) {
if props == nil {
return "", "", false
}
part, ok := props["part"].(map[string]any)
if !ok {
return "", "", false
}
id, _ := part["id"].(string)
partType, _ := part["type"].(string)
if id == "" || partType == "" {
return "", "", false
}
return id, partType, true
}
func opencodeMessagePartDeltaText(props map[string]any, partTypes map[string]string) string {
if props == nil {
return ""
}
field, _ := props["field"].(string)
if field != "text" {
return ""
}
partID, _ := props["partID"].(string)
// Only reject the delta when we positively know the part is not text type.
// If message.part.updated was missed (e.g. second SSE connection opened after
// the server already emitted it), partTypes has no entry and we allow the
// delta rather than silently dropping it.
if partID != "" {
if pt, known := partTypes[partID]; known && pt != "text" {
return ""
}
}
delta, _ := props["delta"].(string)
return delta
}
func opencodeStepTokens(props map[string]any) (int, int, bool) {
if props == nil {
return 0, 0, false
}
tokens, ok := props["tokens"].(map[string]any)
if !ok {
if part, partOK := props["part"].(map[string]any); partOK {
tokens, ok = part["tokens"].(map[string]any)
}
}
if !ok {
return 0, 0, false
}
in := intFromNumber(tokens["input"])
out := intFromNumber(tokens["output"])
return in, out, true
}
func intFromNumber(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i)
}
}
return 0
}
func emitOpencodeComplete(ctx context.Context, sink runtime.EventSink, runID string, inputTokens, outputTokens int) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeComplete,
Message: "opencode sse execution complete",
Usage: &runtime.UsageStats{
InputTokens: inputTokens,
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
}
// opencodeModelPayload parses "provider/model" into the OpenCode prompt_async
// model object {"providerID": provider, "modelID": model}. Returns an error
// when either side is missing.
func opencodeModelPayload(s string) (map[string]any, error) {
idx := strings.Index(s, "/")
if idx < 0 {
return nil, fmt.Errorf("cli adapter: opencode model %q missing provider/model separator", s)
}
provider := s[:idx]
model := s[idx+1:]
if provider == "" || model == "" {
return nil, fmt.Errorf("cli adapter: opencode model %q has empty provider or model", s)
}
return map[string]any{"providerID": provider, "modelID": model}, nil
}
// opencodeStatusIdle reports whether a session.status event's status payload
// represents idle. OpenCode emits the status as a SessionStatus object
// ({"type":"idle"}); older builds may use the bare string "idle".
func opencodeStatusIdle(props map[string]any) bool {
if props == nil {
return false
}
switch v := props["status"].(type) {
case string:
return v == "idle"
case map[string]any:
t, _ := v["type"].(string)
return t == "idle"
}
return false
}
func opencodeErrorMessage(props map[string]any) string {
if props == nil {
return "opencode error"
}
if errObj, ok := props["error"].(map[string]any); ok {
if data, ok := errObj["data"].(map[string]any); ok {
if m, ok := data["message"].(string); ok && m != "" {
return m
}
}
if name, ok := errObj["name"].(string); ok && name != "" {
return name
}
}
if m, ok := props["message"].(string); ok && m != "" {
return m
}
return "opencode error"
}
// --- HTTP helpers ---
func opencodeCreateSession(ctx context.Context, baseURL, title string) (string, error) {
payload := map[string]any{}
if title != "" {
payload["title"] = title
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/session", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("opencode create session status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var raw map[string]any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return "", fmt.Errorf("decode session response: %w", err)
}
if id, ok := raw["id"].(string); ok && id != "" {
return id, nil
}
if s, ok := raw["session"].(map[string]any); ok {
if id, ok := s["id"].(string); ok && id != "" {
return id, nil
}
}
return "", errors.New("opencode create session: missing id in response")
}
func opencodePromptAsync(ctx context.Context, baseURL, sessionID, prompt string, opts opencodeRunOpts) error {
payload := map[string]any{
"parts": []map[string]any{
{"type": "text", "text": prompt},
},
}
if opts.Model != "" {
model, err := opencodeModelPayload(opts.Model)
if err != nil {
return err
}
payload["model"] = model
}
if opts.Agent != "" {
payload["agent"] = opts.Agent
}
if opts.Variant != "" {
payload["variant"] = opts.Variant
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/session/"+sessionID+"/prompt_async", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func opencodePermissionReply(ctx context.Context, baseURL, permID, reply string) error {
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(map[string]any{"reply": reply}); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/permission/"+permID+"/reply", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func opencodeAbort(ctx context.Context, baseURL, sessionID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/session/"+sessionID+"/abort", nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// closeOpencodeSession kills the owned local server process if any. For
// attached sessions, no process is owned and no kill happens.
func closeOpencodeSession(sess *opencodeSSESession) {
if sess == nil {
return
}
if sess.owned && sess.cmd != nil && sess.cmd.Process != nil {
_ = sess.cmd.Process.Kill()
}
}