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 emitReturnedError(ctx, sink, spec.RunID, 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 { // A cancellation while the async prompt POST is in flight surfaces as a // request error; honor the cancellation contract with a bounded background // abort on the remote session, a single cancelled event, and // ErrRunCancelled instead of a generic prompt-async error. if ctx.Err() != nil { bg, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if sess.sessionID != "" { _ = opencodeAbort(bg, sess.serverURL, sess.sessionID) } _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(), }) return runtime.ErrRunCancelled } _ = 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 := newSessionKey(spec) 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, opts, spec.Workspace, 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, k.workspace}) } return snaps } // Terminate closes every workspace variant of the given target/sessionID, // killing each owned local server process. func (e *opencodeExecutor) Terminate(_ context.Context, target, sessionID string) (bool, error) { sid := normalizeSessionID(sessionID) e.mu.Lock() var matched []*opencodeSSESession for k, sess := range e.sessions { if k.target == target && k.sessionID == sid { matched = append(matched, sess) delete(e.sessions, k) } } e.mu.Unlock() if len(matched) == 0 { return false, nil } for _, sess := range matched { 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, opts opencodeRunOpts, workspace string, logger *zap.Logger) (string, *exec.Cmd, error) { args := []string{"serve", "--hostname", "127.0.0.1", "--port", "0"} var dir string if opts.Dir != "" { dir = opts.Dir } else { dir = workspace } cmd, err := buildCmdWithoutContext(profile.Command, args, profile.Env, dir) if err != nil { return "", nil, err } 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() } } // 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" } type opencodeSSERun struct { executor *opencodeExecutor ctx context.Context spec runtime.ExecutionSpec sess *opencodeSSESession opts opencodeRunOpts body io.Reader sink runtime.EventSink events chan evt scanErrCh chan error inputTokens int outputTokens int partTypes map[string]string traceRing [8]sseEvtTrace traceHead int traceSeen int } type evt struct { ev opencodeEnvelope raw []byte } func (r *opencodeSSERun) addTrace(entry sseEvtTrace) { r.traceRing[r.traceHead%8] = entry r.traceHead++ r.traceSeen++ } func (r *opencodeSSERun) recentTrace() []sseEvtTrace { n := r.traceHead if n > 8 { n = 8 } out := make([]sseEvtTrace, n) start := r.traceHead - n for i := 0; i < n; i++ { out[i] = r.traceRing[(start+i)%8] } return out } func (r *opencodeSSERun) finalize(reason error) error { bg, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if r.sess.sessionID != "" { _ = opencodeAbort(bg, r.sess.serverURL, r.sess.sessionID) } if r.executor.cli.logger != nil { r.executor.cli.logger.Warn("opencode sse run cancelled without completion", zap.String("run_id", r.spec.RunID), zap.String("reason", reason.Error()), zap.Int("total_events", r.traceSeen), zap.Any("recent_events", r.recentTrace()), ) } _ = r.sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: r.spec.RunID, Type: runtime.EventTypeCancelled, Message: cancelEventForContext(reason), Timestamp: time.Now(), }) return runtime.ErrRunCancelled } func (r *opencodeSSERun) drive() error { r.events = make(chan evt, 32) r.scanErrCh = make(chan error, 1) r.partTypes = map[string]string{} go func() { defer close(r.events) s := bufio.NewScanner(r.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) r.events <- evt{ev: env, raw: cp} } r.scanErrCh <- s.Err() }() for { select { case <-r.ctx.Done(): return r.finalize(r.ctx.Err()) case e, ok := <-r.events: if !ok { if r.ctx.Err() != nil { return r.finalize(r.ctx.Err()) } if err := <-r.scanErrCh; err != nil && !errors.Is(err, io.EOF) { _ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{ RunID: r.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(r.ctx, r.sink, r.spec.RunID, r.inputTokens, r.outputTokens) } // Filter events that carry a sessionID mismatching ours, when present. if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" && r.sess.sessionID != "" && sid != r.sess.sessionID { r.addTrace(sseEvtTrace{Type: e.ev.Type, SID: "mm", Outcome: "filtered"}) continue } sidStatus := "" if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" { sidStatus = "ok" } done, err := r.handleEvent(e.ev, sidStatus) if done { return err } } } } func (e *opencodeExecutor) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error { run := &opencodeSSERun{ executor: e, ctx: ctx, spec: spec, sess: sess, opts: opts, body: body, sink: sink, } return run.drive() } // --- 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() } }