Adds native support for Devin CLI (Cognition, https://cli.devin.ai/) as a named cc-connect agent, alongside Claude Code / Codex / Cursor / Gemini / iFlow / Qoder / OpenCode. Users can now configure Devin with a minimal `type = "devin"` block rather than hand-rolling a generic ACP config. [[projects]] name = "my-project" [projects.agent] type = "devin" # NEW [projects.agent.options] work_dir = "/path/to/code" # command / args / display_name default to "devin" / ["acp"] / "Devin" # mode, auth_method, env (for WINDSURF_API_KEY) passed through to ACP Credentials come from the local `devin auth login` store — cc-connect never sees or forwards any token. Windsurf Enterprise users can inject WINDSURF_API_KEY via the agent env option. The new `agent/devin/` package is a thin wrapper around `agent/acp/`: Devin speaks the Agent Client Protocol over stdio via its `devin acp` subcommand, so the session/prompt, session/update, permission, and tool-call plumbing is shared. Embedding *acp.Agent and shadowing only Name() gives us identity and defaulting without duplicating transport code: type Agent struct { *acp.Agent } func (a *Agent) Name() string { return "devin" } Wiring Devin revealed two protocol features that agent/acp had left as MVP-skipped TODOs. Both are needed for `/list` and `/mode` to work in IM, so this PR fills them in as part of the Devin integration — Cursor Agent, OpenClaw, and Copilot CLI all pick up the same improvements automatically. ListSessions now spawns a short-lived `<command>` process that runs just initialize + session/list, then tears down. Capability is discovered via `agentCapabilities.sessionCapabilities.list`, and a negative result is cached on the Agent so non-supporting servers pay the spawn cost only once. Soft-degrades on JSON-RPC -32601 (method not found) and -32600 (invalid request). Measured cost against `devin acp` 2026.4.9-0: ~40 ms round trip. Agent implements core.ModeSwitcher (SetMode / GetMode / PermissionModes); the modes list is sourced from the modes block returned by session/new or session/load. GetMode precedence: the most recent explicit SetMode wins over the last server-advertised currentModeId, so `/mode plan` reports the new mode back to the user immediately rather than the cached previous one. acpSession implements core.LiveModeSwitcher via session/set_mode with client-side modeId validation against the cached availableModes. Client-side validation is mandatory because at least one ACP server (devin acp 2026.4.9-0) silently accepts unknown modeIds with an empty result — server-only validation would let typos go undetected. session/update notifications with sessionUpdate = current_mode_update are absorbed so cc-connect stays in sync when the user switches modes from another client (e.g. the Windsurf IDE). newACPSession moved from seven positional args to an acpSessionConfig struct. Private API, not a breaking change externally; the one exported consumer (cursor_integration_test.go) is updated. A sessionCallbacks interface lets running acpSession instances report handshake-derived capabilities and modes back to the parent Agent, since the engine (not the agent) owns the session lifetime. - README.md / README.zh-CN.md: Devin added to the Support Matrix and the agent count bumped from 7 to 8. - config.example.toml: dedicated Devin section placed before the generic ACP examples, with prerequisites and override notes. - web/src/pages/Projects/ProjectList.tsx: Devin added to the agent dropdown so the web admin UI surfaces it alongside other first-class agents. - Makefile: ALL_AGENTS += devin (enables selective compilation tags `no_devin` / AGENTS=devin,...). 22 new tests total (7 for agent/devin, 15 for agent/acp). All pass with -race. agent/acp wrapper tests avoid requiring `devin` in PATH by pinning `command = "true"` to satisfy the upstream exec.LookPath check while still exercising the Name() shadow and default injection. End-to-end verified against live `devin acp` 2026.4.9-0 through the cc-connect bridge: - /mode lists Devin's five modes with ▶ Code marked current - /mode plan switches and the engine reports "switched to **Plan**" - /list shows "devin Sessions (1)" (confirms Name() propagates to UI) - agent field in engine logs = "devin" (not "acp") Closes none — net-new agent support. Generated with [Devin](https://cli.devin.ai/docs) Co-authored-by: youngshook <youngshook@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
94 lines
3.4 KiB
Go
94 lines
3.4 KiB
Go
// Package devin integrates Devin CLI (https://cli.devin.ai/) as a
|
|
// first-class cc-connect agent.
|
|
//
|
|
// Devin speaks the Agent Client Protocol (ACP) over stdio via its
|
|
// `devin acp` subcommand, so the transport and session plumbing is
|
|
// shared with the generic agent/acp package. This package is a thin
|
|
// wrapper that:
|
|
//
|
|
// 1. Registers Devin under the stable config name `type = "devin"`
|
|
// (parallel to `claudecode`, `cursor`, `codex`, etc.), so minimal
|
|
// user config doesn't need to spell out the ACP command / args.
|
|
// 2. Pins Devin-specific defaults — binary name "devin", subcommand
|
|
// "acp", human-readable display name "Devin" — while leaving every
|
|
// underlying ACP option (mode, auth_method, env, work_dir, etc.)
|
|
// overridable from project config.
|
|
// 3. Reports Name() = "devin" so cc-connect's session store keys,
|
|
// audit logs, and /doctor output attribute activity to Devin
|
|
// rather than to the generic "acp" adapter.
|
|
//
|
|
// Authentication is delegated entirely to the local Devin CLI: after a
|
|
// one-time `devin auth login`, the spawned `devin acp` subprocess
|
|
// reads the credentials stored on disk, so cc-connect never needs to
|
|
// see or forward any API tokens. Windsurf Enterprise users can
|
|
// alternatively inject WINDSURF_API_KEY via the agent env option.
|
|
package devin
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/chenhg5/cc-connect/agent/acp"
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func init() {
|
|
core.RegisterAgent("devin", New)
|
|
}
|
|
|
|
// Agent embeds *acp.Agent so it inherits StartSession, ListSessions,
|
|
// ModeSwitcher, AgentDoctorInfo, and all other optional capability
|
|
// interfaces implemented by the ACP adapter — only Name() is
|
|
// overridden so the engine identifies this as a Devin agent.
|
|
type Agent struct {
|
|
*acp.Agent
|
|
}
|
|
|
|
// Name returns the stable agent type identifier used in config,
|
|
// session store keys, and audit logging.
|
|
func (a *Agent) Name() string { return "devin" }
|
|
|
|
// New builds a Devin agent from project options.
|
|
//
|
|
// Option handling:
|
|
// - "command" defaults to "devin" (override only if you have the
|
|
// binary at a non-standard path; always use an absolute path when
|
|
// running under systemd / launchd where PATH is minimal).
|
|
// - "args" defaults to ["acp"].
|
|
// - "display_name" defaults to "Devin".
|
|
// - All other ACP options (work_dir, mode, auth_method, env) are
|
|
// passed through unchanged to agent/acp.
|
|
func New(opts map[string]any) (core.Agent, error) {
|
|
a, err := acp.New(applyDevinDefaults(opts))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base, ok := a.(*acp.Agent)
|
|
if !ok {
|
|
// agent/acp.New always returns *acp.Agent today; if the
|
|
// concrete type ever changes, fall through with a plain
|
|
// wrapper rather than panicking.
|
|
return a, nil
|
|
}
|
|
return &Agent{Agent: base}, nil
|
|
}
|
|
|
|
// applyDevinDefaults returns a new opts map with Devin-specific
|
|
// defaults filled in for any missing / blank fields. Extracted so
|
|
// unit tests can exercise the defaulting logic without requiring
|
|
// `devin` to be present in $PATH (which agent/acp.New would check).
|
|
func applyDevinDefaults(opts map[string]any) map[string]any {
|
|
if opts == nil {
|
|
opts = make(map[string]any)
|
|
}
|
|
if existing, _ := opts["command"].(string); strings.TrimSpace(existing) == "" {
|
|
opts["command"] = "devin"
|
|
}
|
|
if _, ok := opts["args"]; !ok {
|
|
opts["args"] = []string{"acp"}
|
|
}
|
|
if existing, _ := opts["display_name"].(string); strings.TrimSpace(existing) == "" {
|
|
opts["display_name"] = "Devin"
|
|
}
|
|
return opts
|
|
}
|