Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

206 lines
8.0 KiB
Go

package agentruntime
// Cursor CLI's ACP provider config. Cursor speaks ACP natively via the
// `cursor-agent acp` subcommand (newer installers ship the binary as `agent`).
//
// Model selection is live: session/new advertises a `model` config option
// (parameterized ids such as "claude-sonnet-5[thinking=true,...]", with
// "default[]" for Auto) whose option entries carry {value, name}, so the
// shared engine's config-option flow both surfaces the list to the GUI and
// applies changes via session/set_config_option. The `--model` CLI root flag
// does NOT control the ACP session (the account-persisted choice wins) and
// side-effectfully rewrites ~/.cursor/cli-config.json — never pass it here.
//
// Permissions are exposed as approval tiers (matching the Codex/Claude Code
// experience) rather than Cursor's raw agent/plan/ask execution modes:
//
// read-only -> ACP session mode "ask" (read/search tools only, no changes)
// agent -> ACP session mode "agent"; Cursor sends
// session/request_permission per risky action (default)
// full-access -> ACP session mode "agent"; the client auto-approves every
// session/request_permission
//
// full-access uses live client-side auto-approval rather than the `--force`
// spawn flag so all three tiers switch live (no respawn, no "requires a new
// session" error). Cursor still refuses commands its own deny list blocks
// before it ever asks, so auto-approving the requests it does send matches
// `--force` ("allow unless explicitly denied"). Live-probed against
// cursor-agent 2026.07.01: agent mode prompts for every risky action
// (including shell), while `--auto-review` is inert over ACP — so there is no
// honest "approve for me" middle tier.
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
)
// cursorAgentBinaryNames lists the Cursor CLI binary names in resolution
// order: the long-standing `cursor-agent` name first, then the bare `agent`
// name newer official installers ship.
var cursorAgentBinaryNames = []string{"cursor-agent", "agent"}
// Cursor permission tier ids. "agent" doubles as the pre-tier default mode id
// persisted by earlier sessions, which keeps their behavior unchanged.
const (
cursorPermissionReadOnly = "read-only"
cursorPermissionAgent = "agent"
cursorPermissionFullAccess = "full-access"
)
const cursorPluginDirEnv = "TUTTI_CURSOR_PLUGIN_DIR"
const cursorPromptContextFileEnv = "TUTTI_CURSOR_PROMPT_CONTEXT_FILE"
const cursorACPSessionNewRetryLimit = 1
const cursorACPStartupTimeout = 75 * time.Second
func cursorACPShouldRetrySessionNew(err error) bool {
var callErr *acpCallError
if !errors.As(err, &callErr) || callErr == nil || callErr.Method != acpMethodNewSession {
return false
}
if callErr.Err.Code != -32603 {
return false
}
detail := strings.ToLower(strings.Join([]string{
strings.TrimSpace(callErr.Err.Message),
strings.TrimSpace(string(callErr.Err.Data)),
}, " "))
return strings.Contains(detail, "failed to initialize session services")
}
// cursorACPModeID maps Tutti permission tiers onto Cursor's ACP session
// modes (switched via session/set_mode). Approval strictness within "agent"
// is governed by the spawn command, not the session mode.
func cursorACPModeID(mode string) string {
switch strings.TrimSpace(mode) {
case cursorPermissionReadOnly:
return "ask"
case cursorPermissionAgent, cursorPermissionFullAccess:
return "agent"
default:
return ""
}
}
// cursorAutoApprovePermissionDecision auto-approves permission requests in the
// full-access tier and prompts (returns "") in every other tier.
func cursorAutoApprovePermissionDecision(permissionModeID string) string {
if strings.TrimSpace(permissionModeID) == cursorPermissionFullAccess {
return "approved"
}
return ""
}
// cursorACPCommandResolver picks the installed Cursor CLI binary at spawn
// time so the adapter works with either binary name without a registry
// round-trip. An empty resolution keeps the config's default command.
func cursorACPCommandResolver(context.Context, string) (ProviderCommand, error) {
resolver := runtimecmd.Resolver{}
if path := resolver.ResolveBinary(cursorAgentBinaryNames, nil); path != "" {
return ProviderCommand{Command: []string{path, "acp"}}, nil
}
return ProviderCommand{}, nil
}
func cursorACPCommandWithPluginDir(command []string, session Session) []string {
out := append([]string(nil), command...)
pluginDir := sessionEnvValue(session.Env, cursorPluginDirEnv)
if pluginDir == "" || len(out) == 0 || hasCursorPluginDirArg(out) {
return out
}
return append([]string{out[0], "--plugin-dir", pluginDir}, out[1:]...)
}
func hasCursorPluginDirArg(command []string) bool {
for _, arg := range command {
if arg == "--plugin-dir" || strings.HasPrefix(arg, "--plugin-dir=") {
return true
}
}
return false
}
func cursorACPInitialPromptContext(session Session) (string, error) {
path := strings.TrimSpace(sessionEnvValue(session.Env, cursorPromptContextFileEnv))
if path == "" {
return "", nil
}
content, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read cursor initial prompt context: %w", err)
}
return strings.TrimSpace(string(content)), nil
}
func NewCursorAdapter(transport ProcessTransport) *standardACPAdapter {
return NewCursorAdapterWithHostMetadata(transport, LegacyHostMetadata())
}
func NewCursorAdapterWithHostMetadata(transport ProcessTransport, host HostMetadata) *standardACPAdapter {
descriptor, ok := providerregistry.Find(ProviderCursor)
if !ok {
panic("cursor provider descriptor is missing")
}
return newCursorAdapterFromProviderDescriptor(descriptor, transport, host, cursorACPCommandResolver)
}
func newCursorAdapterFromProviderDescriptor(
descriptor providerregistry.ProviderDescriptor,
transport ProcessTransport,
host HostMetadata,
commandResolver ProviderCommandResolver,
) *standardACPAdapter {
adapter := newStandardACPAdapterFromProviderDescriptor(descriptor, transport, host, commandResolver)
adapter.config.commandWithSettings = cursorACPCommandWithPluginDir
adapter.config.initialPromptContext = cursorACPInitialPromptContext
adapter.config.automaticPermissionDecision = cursorAutoApprovePermissionDecision
adapter.config.providerPermissionRequestDecision = cursorACPQuestionMCPPermissionDecision
adapter.config.localToolBridge = newCursorACPQuestionMCPBridge(adapter)
// Cursor 2026.08 can spend about a minute initializing session-scoped HTTP
// MCP clients, beyond the generic 30-second ACP budget. Keep the wider bound local
// to Cursor so other providers retain the shared fail-fast behavior.
adapter.config.startupTimeout = cursorACPStartupTimeout
adapter.config.autoContinueRetriableTurnError = true
adapter.config.retrySessionNewError = cursorACPShouldRetrySessionNew
adapter.config.sessionNewRetryLimit = cursorACPSessionNewRetryLimit
adapter.config.messageDiagnostics = &standardACPMessageDiagnostics{
method: cursorACPMethodTask,
observeMessage: logCursorACPTaskExtension,
observeUpdate: logCursorACPTaskToolUpdate,
}
adapter.config.providerMessageHandler = func(
ctx context.Context,
client *acpClient,
session Session,
turnID string,
message acpMessage,
normalizer *acpTurnNormalizer,
emit EventSink,
) ([]activityshared.Event, bool, error) {
switch message.Method {
case cursorACPMethodAskQuestion, cursorACPMethodCreatePlan:
events, err := adapter.handleCursorInteractiveMessage(ctx, client, session, turnID, message, normalizer, emit)
return events, true, err
default:
return nil, false, nil
}
}
return adapter
}
func newCursorAdapterWithHostMetadata(transport ProcessTransport, host HostMetadata, commandResolver ProviderCommandResolver) *standardACPAdapter {
descriptor, ok := providerregistry.Find(ProviderCursor)
if !ok {
panic("cursor provider descriptor is missing")
}
return newCursorAdapterFromProviderDescriptor(descriptor, transport, host, commandResolver)
}