Files
2026-06-02 23:14:41 +08:00

763 lines
21 KiB
Go

package acp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
)
// toolInputCacheMaxEntries caps toolInputByID growth; beyond this we evict
// roughly half the map (iteration order is arbitrary) to bound memory.
const toolInputCacheMaxEntries = 1000
type acpSession struct {
workDir string
events chan core.Event
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
cmd *exec.Cmd
tr *transport
acpSessMu sync.RWMutex
acpSessID string
sendMu sync.Mutex
permMu sync.Mutex
permByID map[string]permState
toolInputMu sync.Mutex
toolInputByID map[string]string // toolCallId -> summarized tool input
// modesMu guards availableModes and currentMode. Both fields are
// populated on handshake (session/new or session/load response) and
// updated whenever SetLiveMode succeeds or the server announces a
// mode change via session/update.
modesMu sync.RWMutex
availableModes []acpModeInfo
currentMode string
callbacks sessionCallbacks // may be nil (tests, integration harness)
}
type permState struct {
RPCID json.RawMessage
Options []permissionOption
}
// acpSessionConfig bundles the inputs newACPSession needs. It's a
// struct rather than a long positional argument list because we keep
// adding optional knobs (initialMode, callbacks) and would otherwise
// break every call site each time.
type acpSessionConfig struct {
command string
args []string
extraEnv []string
workDir string
resumeSessionID string
authMethod string
initialMode string // if non-empty, applied via session/set_mode after session/new
callbacks sessionCallbacks // may be nil
}
func newACPSession(ctx context.Context, cfg acpSessionConfig) (*acpSession, error) {
absWorkDir, err := filepath.Abs(cfg.workDir)
if err != nil {
absWorkDir = cfg.workDir
}
sessionCtx, cancel := context.WithCancel(ctx)
s := &acpSession{
workDir: absWorkDir,
events: make(chan core.Event, 128),
ctx: sessionCtx,
cancel: cancel,
permByID: make(map[string]permState),
toolInputByID: make(map[string]string),
acpSessID: cfg.resumeSessionID,
callbacks: cfg.callbacks,
}
s.alive.Store(true)
cmd := exec.CommandContext(sessionCtx, cfg.command, cfg.args...)
cmd.Dir = absWorkDir
cmd.Env = core.MergeEnv(os.Environ(), cfg.extraEnv)
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = io.MultiWriter(&stderrBuf, os.Stderr)
s.cmd = cmd
s.tr = newTransport(stdout, stdin, s.onNotification, s.onServerRequest)
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("acp: start %s: %w", cfg.command, err)
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.tr.readLoop(sessionCtx)
waitErr := cmd.Wait()
if waitErr != nil {
msg := stderrBuf.String()
if msg != "" {
slog.Error("acp: process exited", "error", waitErr, "stderr", msg)
s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("%s", strings.TrimSpace(msg))})
} else {
slog.Debug("acp: process exited", "error", waitErr)
}
}
s.alive.Store(false)
}()
if err := s.handshake(cfg.resumeSessionID, cfg.authMethod); err != nil {
_ = s.Close()
return nil, err
}
// Apply the agent-level mode preference now that we have a session
// id. If set_mode fails (e.g. modeId unknown to this backend) we
// log and carry on with whatever mode the server defaulted to —
// the alternative would be to reject the session entirely, which
// is worse UX for a non-critical control.
if strings.TrimSpace(cfg.initialMode) != "" {
if ok := s.SetLiveMode(cfg.initialMode); !ok {
slog.Warn("acp: initial mode could not be applied",
"mode", cfg.initialMode,
"session_id", s.currentACPSessionID(),
)
}
}
return s, nil
}
// handshake runs initialize → optional authenticate → session/load or
// session/new, and caches any modes the server advertises so
// SetLiveMode / PermissionModes can answer correctly.
func (s *acpSession) handshake(resumeSessionID string, authMethod string) error {
initParams := map[string]any{
"protocolVersion": 1,
"clientCapabilities": map[string]any{
"fs": map[string]any{
"readTextFile": false,
"writeTextFile": false,
},
"terminal": false,
},
"clientInfo": map[string]any{
"name": "cc-connect",
"version": "1.0.0",
},
}
res, err := s.tr.call(s.ctx, "initialize", initParams)
if err != nil {
return fmt.Errorf("acp: initialize: %w", err)
}
var initOut acpInitializeResult
if err := json.Unmarshal(res, &initOut); err != nil {
return fmt.Errorf("acp: parse initialize result: %w", err)
}
listSupported := len(initOut.AgentCapabilities.SessionCapabilities.List) > 0
slog.Debug("acp: initialized",
"protocol", initOut.ProtocolVersion,
"load_session", initOut.AgentCapabilities.LoadSession,
"list_sessions", listSupported,
)
if s.callbacks != nil {
s.callbacks.reportListSupported(listSupported)
}
if strings.TrimSpace(authMethod) != "" {
if _, err := s.tr.call(s.ctx, "authenticate", map[string]any{
"methodId": authMethod,
}); err != nil {
return fmt.Errorf("acp: authenticate (%s): %w", authMethod, err)
}
slog.Debug("acp: authenticated", "method_id", authMethod)
}
wantResume := resumeSessionID != "" && resumeSessionID != core.ContinueSession
if wantResume && initOut.AgentCapabilities.LoadSession {
loadParams := map[string]any{
"sessionId": resumeSessionID,
"cwd": s.workDir,
"mcpServers": []any{},
}
loadRes, err := s.tr.call(s.ctx, "session/load", loadParams)
if err != nil {
slog.Warn("acp: session/load failed, starting new session", "error", err)
} else {
var lr struct {
SessionID string `json:"sessionId"`
Modes *acpModesBlock `json:"modes"`
}
if json.Unmarshal(loadRes, &lr) == nil && lr.SessionID != "" {
s.setACPSessionID(lr.SessionID)
s.absorbModes(lr.Modes)
return nil
}
}
}
newParams := map[string]any{
"cwd": s.workDir,
"mcpServers": []any{},
}
newRes, err := s.tr.call(s.ctx, "session/new", newParams)
if err != nil {
return fmt.Errorf("acp: session/new: %w", err)
}
var sn struct {
SessionID string `json:"sessionId"`
Modes *acpModesBlock `json:"modes"`
}
if err := json.Unmarshal(newRes, &sn); err != nil {
return fmt.Errorf("acp: parse session/new: %w", err)
}
if sn.SessionID == "" {
return fmt.Errorf("acp: session/new: empty sessionId")
}
s.setACPSessionID(sn.SessionID)
s.absorbModes(sn.Modes)
return nil
}
// absorbModes copies a modes block into the session's cache and fans
// it out to the parent agent callbacks (if any). Both the session and
// the agent need the information: the session uses it to validate
// SetLiveMode inputs; the agent uses it to render `/mode` menus in IM.
func (s *acpSession) absorbModes(block *acpModesBlock) {
if block == nil || len(block.AvailableModes) == 0 {
return
}
s.modesMu.Lock()
s.availableModes = append(s.availableModes[:0], block.AvailableModes...)
if block.CurrentModeID != "" {
s.currentMode = block.CurrentModeID
}
s.modesMu.Unlock()
if s.callbacks != nil {
s.callbacks.reportModes(*block)
}
}
func (s *acpSession) setACPSessionID(id string) {
s.acpSessMu.Lock()
s.acpSessID = id
s.acpSessMu.Unlock()
}
func (s *acpSession) currentACPSessionID() string {
s.acpSessMu.RLock()
defer s.acpSessMu.RUnlock()
return s.acpSessID
}
// CurrentMode returns the ACP modeId most recently applied or reported
// for this session. Empty when the server never sent a modes block.
func (s *acpSession) CurrentMode() string {
s.modesMu.RLock()
defer s.modesMu.RUnlock()
return s.currentMode
}
// SetLiveMode applies a permission mode change to the running session
// via `session/set_mode`. Returns true on success, false if the mode
// is unknown / the call errors / the session is closed.
//
// This is the implementation of core.LiveModeSwitcher for ACP
// sessions; the engine invokes it when the user runs `/mode <x>`,
// `/plan`, `/bypass`, etc. while a session is active.
//
// Client-side validation is important because at least one ACP server
// (devin acp in 2026.4.9) silently accepts unknown modeIds without
// any error, so a server-only check would let typos go undetected.
func (s *acpSession) SetLiveMode(mode string) bool {
if !s.alive.Load() {
return false
}
sid := s.currentACPSessionID()
if sid == "" {
return false
}
modeID := s.matchAvailableMode(mode)
if modeID == "" {
slog.Debug("acp: SetLiveMode rejected unknown mode",
"mode", mode,
"session_id", sid,
)
return false
}
if _, err := s.tr.call(s.ctx, "session/set_mode", map[string]any{
"sessionId": sid,
"modeId": modeID,
}); err != nil {
slog.Warn("acp: session/set_mode failed",
"mode", modeID,
"session_id", sid,
"error", err,
)
return false
}
s.modesMu.Lock()
s.currentMode = modeID
s.modesMu.Unlock()
if s.callbacks != nil {
// Re-publish current modeId so Agent.GetMode stays in sync.
s.modesMu.RLock()
available := append([]acpModeInfo(nil), s.availableModes...)
s.modesMu.RUnlock()
s.callbacks.reportModes(acpModesBlock{
CurrentModeID: modeID,
AvailableModes: available,
})
}
slog.Info("acp: live mode applied", "mode", modeID, "session_id", sid)
return true
}
// matchAvailableMode resolves a user-typed mode string to a known ACP
// modeId from the cached availableModes list. Matching is case-
// insensitive on both id and display name to accommodate IM input.
// Returns "" if nothing matches or if modes are unknown (first session
// hasn't handshaked yet).
func (s *acpSession) matchAvailableMode(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return ""
}
lower := strings.ToLower(input)
s.modesMu.RLock()
defer s.modesMu.RUnlock()
for _, m := range s.availableModes {
if strings.ToLower(m.ID) == lower || strings.ToLower(m.Name) == lower {
return m.ID
}
}
return ""
}
func (s *acpSession) onNotification(method string, params json.RawMessage) {
if method != "session/update" {
slog.Debug("acp: notification", "method", method)
return
}
s.cacheToolCallInput(params)
s.maybeAbsorbCurrentModeUpdate(params)
sid := s.currentACPSessionID()
for _, ev := range mapSessionUpdate(sid, params) {
s.emit(ev)
}
}
// maybeAbsorbCurrentModeUpdate watches session/update notifications
// for `current_mode_update` (server-driven mode switch, e.g. when the
// user toggles modes via the Windsurf/IDE UI while cc-connect is
// connected). Keeping currentMode in sync here means the IM `/mode`
// indicator reflects the true server state rather than the last
// client-initiated value.
func (s *acpSession) maybeAbsorbCurrentModeUpdate(params json.RawMessage) {
var wrap struct {
Update json.RawMessage `json:"update"`
}
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
return
}
var head struct {
Kind string `json:"sessionUpdate"`
CurrentModeID string `json:"currentModeId"`
}
if json.Unmarshal(wrap.Update, &head) != nil {
return
}
if head.Kind != "current_mode_update" || head.CurrentModeID == "" {
return
}
s.modesMu.Lock()
s.currentMode = head.CurrentModeID
available := append([]acpModeInfo(nil), s.availableModes...)
s.modesMu.Unlock()
if s.callbacks != nil {
s.callbacks.reportModes(acpModesBlock{
CurrentModeID: head.CurrentModeID,
AvailableModes: available,
})
}
}
// cacheToolCallInput extracts and caches rawInput from tool_call and tool_call_update
// session updates so that handlePermissionRequest can look it up by toolCallId.
// OpenCode ACP bug (#7370): rawInput is empty in tool_call and request_permission,
// but populated in tool_call_update. We cache from both sources.
func (s *acpSession) evictToolInputCacheIfNeededLocked() {
if len(s.toolInputByID) < toolInputCacheMaxEntries {
return
}
target := toolInputCacheMaxEntries / 2
for k := range s.toolInputByID {
if len(s.toolInputByID) <= target {
break
}
delete(s.toolInputByID, k)
}
}
func (s *acpSession) cacheToolCallInput(params json.RawMessage) {
var wrap struct {
Update json.RawMessage `json:"update"`
}
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
return
}
var head struct {
SessionUpdate string `json:"sessionUpdate"`
}
if json.Unmarshal(wrap.Update, &head) != nil {
return
}
switch head.SessionUpdate {
case "tool_call":
var tc struct {
ToolCallID string `json:"toolCallId"`
Kind string `json:"kind"`
RawInput json.RawMessage `json:"rawInput"`
}
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
return
}
s.toolInputMu.Lock()
s.evictToolInputCacheIfNeededLocked()
input := summarizeACPToolInput(tc.Kind, tc.RawInput)
s.toolInputByID[tc.ToolCallID] = input
s.toolInputMu.Unlock()
slog.Info("acp: cached tool_call input", "toolCallId", tc.ToolCallID, "kind", tc.Kind, "input", input)
case "tool_call_update":
var tc struct {
ToolCallID string `json:"toolCallId"`
RawInput json.RawMessage `json:"rawInput"`
}
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
return
}
input := summarizeACPToolInput("", tc.RawInput)
if input == "" {
return
}
s.toolInputMu.Lock()
s.evictToolInputCacheIfNeededLocked()
s.toolInputByID[tc.ToolCallID] = input
s.toolInputMu.Unlock()
slog.Info("acp: cached tool_call_update input", "toolCallId", tc.ToolCallID, "input", input)
}
}
func (s *acpSession) onServerRequest(method string, id json.RawMessage, params json.RawMessage) {
switch method {
case "session/request_permission":
s.handlePermissionRequest(id, params)
case "cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image":
// Cursor CLI extensions — acknowledge so tool flows do not block; IM UX is limited for these.
slog.Debug("acp: cursor extension request (no-op ack)", "method", method)
_ = s.tr.respondSuccess(id, map[string]any{})
default:
if strings.HasPrefix(method, "cursor/") {
slog.Debug("acp: unknown cursor extension, ack empty", "method", method)
_ = s.tr.respondSuccess(id, map[string]any{})
return
}
slog.Info("acp: unhandled server request", "method", method)
_ = s.tr.respondError(id, -32601, "method not implemented")
}
}
func (s *acpSession) handlePermissionRequest(id json.RawMessage, params json.RawMessage) {
var p struct {
SessionID string `json:"sessionId"`
ToolCall struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title"`
Kind string `json:"kind"`
RawInput json.RawMessage `json:"rawInput"`
} `json:"toolCall"`
Options []permissionOption `json:"options"`
}
if err := json.Unmarshal(params, &p); err != nil {
_ = s.tr.respondError(id, -32602, "invalid params")
return
}
slog.Debug("acp: permission request raw params", "params", string(params))
reqKey := jsonIDKey(id)
toolName := p.ToolCall.Title
if toolName == "" {
toolName = p.ToolCall.Kind
}
if toolName == "" {
toolName = "permission"
}
s.permMu.Lock()
s.permByID[reqKey] = permState{RPCID: id, Options: p.Options}
s.permMu.Unlock()
rawTool := map[string]any{}
_ = json.Unmarshal(params, &rawTool)
// OpenCode ACP bug (#7370): rawInput in request_permission is always {},
// but tool_call_update (which arrives right after) has the real input.
// Emit in a goroutine so we don't block the read loop, and wait briefly
// for tool_call_update to populate the cache.
s.wg.Add(1)
go func() {
defer s.wg.Done()
for i := 0; i < 10; i++ {
s.toolInputMu.Lock()
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
s.toolInputMu.Unlock()
if toolInput != "" {
break
}
select {
case <-s.ctx.Done():
return
case <-time.After(50 * time.Millisecond):
}
}
s.toolInputMu.Lock()
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
s.toolInputMu.Unlock()
if toolInput == "" {
toolInput = summarizeACPToolInput(p.ToolCall.Kind, p.ToolCall.RawInput)
}
if toolInput == "" {
toolInput = p.ToolCall.Title
}
if toolInput == "" {
toolInput = p.ToolCall.ToolCallID
}
slog.Info("acp: permission request", "request_id", reqKey, "tool", toolName, "input", toolInput)
s.emit(core.Event{
Type: core.EventPermissionRequest,
RequestID: reqKey,
ToolName: toolName,
ToolInput: toolInput,
ToolInputRaw: rawTool,
SessionID: s.currentACPSessionID(),
})
}()
}
func (s *acpSession) emit(ev core.Event) {
if ev.SessionID == "" {
ev.SessionID = s.currentACPSessionID()
}
select {
case s.events <- ev:
case <-s.ctx.Done():
}
}
func (s *acpSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if !s.alive.Load() {
return fmt.Errorf("acp: session closed")
}
s.sendMu.Lock()
defer s.sendMu.Unlock()
filePaths := core.SaveFilesToDisk(s.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
if len(images) > 0 {
prompt = s.appendImageRefs(prompt, images)
}
sid := s.currentACPSessionID()
if sid == "" {
return fmt.Errorf("acp: no agent session id")
}
promptBlocks := []any{
map[string]any{"type": "text", "text": prompt},
}
params := map[string]any{
"sessionId": sid,
"prompt": promptBlocks,
}
_, err := s.tr.call(s.ctx, "session/prompt", params)
if err != nil {
s.emit(core.Event{Type: core.EventError, Error: err})
return fmt.Errorf("acp: session/prompt: %w", err)
}
// Text was streamed via session/update; engine aggregates EventText.
s.emit(core.Event{
Type: core.EventResult,
SessionID: sid,
Done: true,
})
return nil
}
func (s *acpSession) appendImageRefs(prompt string, images []core.ImageAttachment) string {
attachDir := filepath.Join(s.workDir, ".cc-connect", "attachments")
if err := os.MkdirAll(attachDir, 0o755); err != nil {
slog.Warn("acp: mkdir attachments failed", "error", err)
return prompt
}
var paths []string
for i, img := range images {
ext := ".bin"
switch img.MimeType {
case "image/jpeg":
ext = ".jpg"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
case "image/png", "":
ext = ".png"
}
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(attachDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
slog.Error("acp: save image failed", "error", err)
continue
}
paths = append(paths, fpath)
}
if len(paths) == 0 {
return prompt
}
if prompt == "" {
prompt = "User sent image(s)."
}
return prompt + "\n\n(Image files saved locally: " + strings.Join(paths, ", ") + ")"
}
func (s *acpSession) RespondPermission(requestID string, result core.PermissionResult) error {
if !s.alive.Load() {
return fmt.Errorf("acp: session closed")
}
s.permMu.Lock()
st, ok := s.permByID[requestID]
if ok {
delete(s.permByID, requestID)
}
s.permMu.Unlock()
if !ok {
return fmt.Errorf("acp: unknown permission request %q", requestID)
}
allow := strings.EqualFold(result.Behavior, "allow")
optID := pickPermissionOptionID(allow, st.Options)
if allow && optID == "" {
slog.Warn("acp: allow requested but agent sent no options", "request_id", requestID)
return s.tr.respondError(st.RPCID, -32603, "no permission options from agent")
}
res := buildPermissionResult(allow, optID)
slog.Debug("acp: permission response", "request_id", requestID, "allow", allow, "option_id", optID)
return s.tr.respondSuccess(st.RPCID, res)
}
func (s *acpSession) Events() <-chan core.Event {
return s.events
}
func (s *acpSession) CurrentSessionID() string {
return s.currentACPSessionID()
}
func (s *acpSession) Alive() bool {
return s.alive.Load()
}
func (s *acpSession) Close() error {
s.alive.Store(false)
s.cancel()
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
close(s.events)
case <-time.After(8 * time.Second):
slog.Warn("acp: close timed out waiting for I/O loop")
}
return nil
}
// summarizeACPToolInput extracts a human-readable summary from ACP tool rawInput.
func summarizeACPToolInput(kind string, raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return string(raw)
}
if len(m) == 0 {
return ""
}
switch strings.ToLower(kind) {
case "bash", "shell", "terminal", "execute":
if cmd, ok := m["command"].(string); ok {
if desc, ok := m["description"].(string); ok && desc != "" {
return "# " + desc + "\n" + cmd
}
return cmd
}
case "read", "write", "edit":
if fp, ok := m["file_path"].(string); ok {
return fp
}
if fp, ok := m["path"].(string); ok {
return fp
}
}
// Fallback: try extracting command with description before formatting JSON.
if cmd, ok := m["command"].(string); ok {
if desc, ok := m["description"].(string); ok && desc != "" {
return "# " + desc + "\n" + cmd
}
return cmd
}
b, _ := json.MarshalIndent(m, "", " ")
return string(b)
}