package acp import ( "context" "fmt" "log/slog" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "github.com/chenhg5/cc-connect/core" ) func init() { core.RegisterAgent("acp", New) } // Agent runs an ACP (Agent Client Protocol) agent subprocess over stdio JSON-RPC. type Agent struct { workDir string command string args []string staticEnv map[string]string extraEnv []string sessionEnv []string authMethod string // optional, e.g. "cursor_login" for Cursor CLI (see authenticate RPC) displayName string // optional, for doctor (default "ACP") // mode is the pending permission mode to apply to new sessions. // When set, StartSession applies it via session/set_mode right after // session/new. Empty means "use whatever the agent selects by default". mode string // listUnsupported caches a negative result after we probe the agent // for sessionCapabilities.list once. Eliminates spawn cost on // subsequent `/ls` invocations against agents that don't implement // session/list (e.g. some Copilot/OpenClaw builds). listUnsupported atomic.Bool // modesCache holds the latest `modes` block we observed via // session/new or session/load. It's populated by the session // handshake so that future PermissionModes() calls can reflect the // actual modes this specific ACP agent offers (rather than a // hard-coded fallback that may not match). modesMu sync.RWMutex modesCache []core.PermissionModeInfo modesCurrent string mu sync.RWMutex } // sessionCallbacks lets a running acpSession report what it learned // during the handshake back to its parent Agent. The session is owned // by cc-connect's engine (not the agent), so without this the agent // would never see availableModes / capability advertisements. type sessionCallbacks interface { reportModes(block acpModesBlock) reportListSupported(supported bool) } // Ensure *Agent satisfies sessionCallbacks at compile time. var _ sessionCallbacks = (*Agent)(nil) // New builds an acp agent from project options. // Required: options["command"] — executable name or path for the ACP agent. // Optional: options["args"], options["env"], options["auth_method"], // options["display_name"], options["mode"]. func New(opts map[string]any) (core.Agent, error) { workDir, _ := opts["work_dir"].(string) if workDir == "" { workDir = "." } cmdStr, _ := opts["command"].(string) cmdStr = strings.TrimSpace(cmdStr) if cmdStr == "" { return nil, fmt.Errorf("acp: agent option \"command\" is required (path or name of the ACP agent binary)") } if _, err := exec.LookPath(cmdStr); err != nil { return nil, fmt.Errorf("acp: command %q not found in PATH: %w", cmdStr, err) } args := parseStringSlice(opts["args"]) staticEnv := envMapFromOpts(opts) extra := envPairsFromOpts(opts) authMethod, _ := opts["auth_method"].(string) authMethod = strings.TrimSpace(authMethod) displayName, _ := opts["display_name"].(string) displayName = strings.TrimSpace(displayName) if displayName == "" { displayName = "ACP" } mode, _ := opts["mode"].(string) mode = strings.TrimSpace(mode) return &Agent{ workDir: workDir, command: cmdStr, args: args, staticEnv: staticEnv, extraEnv: extra, authMethod: authMethod, displayName: displayName, mode: mode, }, nil } func envMapFromOpts(opts map[string]any) map[string]string { raw, ok := opts["env"] if !ok || raw == nil { return nil } switch m := raw.(type) { case map[string]string: out := make(map[string]string, len(m)) for k, v := range m { out[k] = v } return out case map[string]any: out := make(map[string]string, len(m)) for k, v := range m { out[k] = fmt.Sprint(v) } return out default: return nil } } func envPairsFromOpts(opts map[string]any) []string { raw, ok := opts["env"] if !ok || raw == nil { return nil } switch m := raw.(type) { case map[string]string: var out []string for k, v := range m { out = append(out, k+"="+v) } return out case map[string]any: var out []string for k, v := range m { out = append(out, fmt.Sprintf("%s=%v", k, v)) } return out default: return nil } } func parseStringSlice(v any) []string { switch x := v.(type) { case nil: return nil case []string: return append([]string(nil), x...) case []any: out := make([]string, 0, len(x)) for _, e := range x { switch t := e.(type) { case string: out = append(out, t) default: out = append(out, fmt.Sprint(t)) } } return out default: return nil } } func (a *Agent) Name() string { return "acp" } func (a *Agent) SetWorkDir(dir string) { a.mu.Lock() a.workDir = dir a.mu.Unlock() slog.Info("acp: work_dir changed", "work_dir", dir) } func (a *Agent) GetWorkDir() string { a.mu.RLock() defer a.mu.RUnlock() return a.workDir } func (a *Agent) WorkspaceAgentOptions() map[string]any { a.mu.RLock() defer a.mu.RUnlock() opts := map[string]any{ "command": a.command, } if len(a.args) > 0 { opts["args"] = append([]string(nil), a.args...) } if len(a.staticEnv) > 0 { env := make(map[string]string, len(a.staticEnv)) for k, v := range a.staticEnv { env[k] = v } opts["env"] = env } if a.authMethod != "" { opts["auth_method"] = a.authMethod } if a.displayName != "" { opts["display_name"] = a.displayName } return opts } func (a *Agent) SetSessionEnv(env []string) { a.mu.Lock() a.sessionEnv = env a.mu.Unlock() } func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { a.mu.RLock() command := a.command args := a.args workDir := a.workDir authMethod := a.authMethod pendingMode := a.mode extra := append([]string(nil), a.extraEnv...) extra = append(extra, a.sessionEnv...) a.mu.RUnlock() return newACPSession(ctx, acpSessionConfig{ command: command, args: args, extraEnv: extra, workDir: workDir, resumeSessionID: sessionID, authMethod: authMethod, initialMode: pendingMode, callbacks: a, }) } func (a *Agent) Stop() error { return nil } // -- AgentDoctorInfo -- func (a *Agent) CLIBinaryName() string { a.mu.RLock() cmd := a.command a.mu.RUnlock() return filepath.Base(cmd) } func (a *Agent) CLIDisplayName() string { a.mu.RLock() n := a.displayName a.mu.RUnlock() if n == "" { return "ACP" } return n } // -- ModeSwitcher -- // // cc-connect's engine treats ModeSwitcher as the point of truth for // both displaying `/mode` options and applying a mode selection. For // the generic ACP adapter we keep the Key == ACP modeId so downstream // `session/set_mode` calls don't need any translation. // SetMode stores a permission mode to apply to future sessions started // via StartSession. If the caller-provided mode matches a known cached // mode id (case-insensitive), it is normalised to that id. Otherwise // it is stored as-is — some IM users may configure modes before the // agent has started any session and thus advertised its mode list. func (a *Agent) SetMode(mode string) { normalised := mode if m := a.matchModeID(mode); m != "" { normalised = m } a.mu.Lock() a.mode = normalised a.mu.Unlock() slog.Info("acp: mode changed for future sessions", "mode", normalised) } // GetMode returns the mode cc-connect will treat as "current" when // rendering the `/mode` picker or applying SetLiveMode. // // Precedence: the most recent explicit SetMode wins (that's the user's // intent — `/mode plan` should immediately be reflected in the next // `/mode` listing even before the session/set_mode RPC has returned). // Only if no one has ever called SetMode for this Agent do we fall // back to whatever the server advertised as currentModeId during the // last handshake. func (a *Agent) GetMode() string { a.mu.RLock() pending := a.mode a.mu.RUnlock() if pending != "" { return pending } a.modesMu.RLock() defer a.modesMu.RUnlock() return a.modesCurrent } // PermissionModes returns the modes this ACP agent offers. The list is // populated from the latest `modes.availableModes` observed on // session/new or session/load; before the first successful handshake // it returns an empty slice, and the engine will hide the mode picker. // // ACP doesn't send per-mode Desc/NameZh, so Description (if the server // sent one) maps to Desc for both locales. IM-side translators are // free to map well-known ids to localised strings later. func (a *Agent) PermissionModes() []core.PermissionModeInfo { a.modesMu.RLock() defer a.modesMu.RUnlock() out := make([]core.PermissionModeInfo, len(a.modesCache)) copy(out, a.modesCache) return out } // matchModeID returns the canonical mode id for a user-typed string // (case-insensitive match on id or display name). Empty string if no // match or if we haven't observed modes yet. func (a *Agent) matchModeID(input string) string { input = strings.TrimSpace(input) if input == "" { return "" } lower := strings.ToLower(input) a.modesMu.RLock() defer a.modesMu.RUnlock() for _, m := range a.modesCache { if strings.ToLower(m.Key) == lower || strings.ToLower(m.Name) == lower { return m.Key } } return "" } // -- sessionCallbacks impl -- func (a *Agent) reportModes(block acpModesBlock) { infos := make([]core.PermissionModeInfo, 0, len(block.AvailableModes)) for _, m := range block.AvailableModes { infos = append(infos, core.PermissionModeInfo{ Key: m.ID, Name: m.Name, NameZh: m.Name, Desc: m.Description, DescZh: m.Description, }) } a.modesMu.Lock() a.modesCache = infos a.modesCurrent = block.CurrentModeID a.modesMu.Unlock() } func (a *Agent) reportListSupported(supported bool) { if !supported { a.listUnsupported.Store(true) } else { a.listUnsupported.Store(false) } }