373 lines
9.6 KiB
Go
373 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|