From 0cb96f62de8b4daf32a738b9a489237cb273a754 Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:24:32 +0530 Subject: [PATCH] feat(agents): add devin adapter Registers the devin harness, stacked on the agent platform. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/adapters/agent/devin/devin.go | 282 ++++++++++++++++++ .../adapters/agent/devin/devin_test.go | 274 +++++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 4 files changed, 559 insertions(+) create mode 100644 backend/internal/adapters/agent/devin/devin.go create mode 100644 backend/internal/adapters/agent/devin/devin_test.go diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go new file mode 100644 index 0000000..e20d609 --- /dev/null +++ b/backend/internal/adapters/agent/devin/devin.go @@ -0,0 +1,282 @@ +// Package devin implements the Devin ("Devin for Terminal", Cognition) agent +// adapter. +// +// Devin for Terminal (binary "devin") is Cognition's terminal coding agent. It +// has a documented Claude Code compatibility layer: it imports `.claude/` +// configuration (commands, subagents, and Claude Code lifecycle hooks), storing +// the converted hooks in `.devin/hooks.v1.json`. Because of this, AO reuses the +// Claude Code hook installer (which writes .claude/settings.local.json with AO +// hook commands) and Devin picks them up via its compat layer. This makes Devin +// a Tier B (Claude-compat) adapter, mirroring the grok adapter. +// +// Launch uses `-p ` for the initial task in non-interactive/print mode +// (in-command delivery). Permission handling uses `--permission-mode`, whose +// valid values are `normal` (aliases: auto) and `dangerous` (aliases: yolo, +// bypass). AO's four permission modes are mapped onto these two: Default emits +// no flag (defer to the user's ~/.config/devin/config.json), AcceptEdits/Auto +// map to `auto`, and BypassPermissions maps to `dangerous`. +// +// Restore prefers the hook-captured native session id via `-r `. Devin +// session ids are listed by `devin list --format json`; AO captures the native +// id through the Claude-compat hook payloads (SessionStart) into session +// metadata, the same path grok uses. +package devin + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + devinTitleMetadataKey = "title" + devinSummaryMetadataKey = "summary" +) + +// Plugin is the Devin for Terminal agent adapter. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Devin adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "devin", + Name: "Devin", + Description: "Run Cognition Devin for Terminal worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds `devin [--permission-mode ] -p `. +// Prompt is delivered via -p (in command, non-interactive print mode). +// +// Permission values come from `devin --permission-mode -h`: +// `normal` (alias auto) and `dangerous` (aliases yolo, bypass). Default omits +// the flag so Devin uses its config (default mode is auto/normal). +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.devinBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal +// has a documented Claude Code compatibility layer. +// +// Official docs (https://docs.devin.ai/cli, Configuration Import / Extensibility): +// Devin reads configuration from `.claude/` including "Commands, custom +// subagents, hooks"; its "Lifecycle hooks (Claude Code compatible)" are stored +// in `.devin/hooks.v1.json`. The binary itself ships a +// `config-importers/.../claude` + `agent-ext/hooks/importers/claude` layer that +// converts Claude hooks (SessionStart, UserPromptSubmit, Stop, PermissionRequest, +// SessionEnd, ...) on load. +// +// This means Devin picks up the .claude/settings.local.json (and the AO hook +// commands we install there) in the worktree. The installed commands are +// "ao hooks claude-code ", so the existing CLI hook dispatcher routes them +// to claude derive logic (Devin is grouped with claude-code in cli/hooks.go). +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg) +} + +// GetRestoreCommand builds `devin [--permission-mode ] -r ` +// when we have a hook-captured native id. ok=false otherwise (fall back to fresh +// launch in the manager). +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.devinBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo reads hook-derived metadata. Since we delegate hook install to +// claude hooks (via compat), the keys in the metadata map are the claude ones +// ("title", "summary", "agentSessionId"). We surface them under the normalized +// SessionInfo. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[devinTitleMetadataKey], + Summary: session.Metadata[devinSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI). +func ResolveDevinBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"devin.cmd", "devin.exe", "devin"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".devin", "bin", "devin.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "devin", nil + } + + if path, err := exec.LookPath("devin"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/devin", + "/opt/homebrew/bin/devin", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".devin", "bin", "devin"), + filepath.Join(home, ".local", "bin", "devin"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "devin", nil +} + +func (p *Plugin) devinBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveDevinBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's four permission modes onto Devin's two native +// permission values (`auto`/normal and `dangerous`/bypass), per +// `devin --permission-mode -h`. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to ~/.config/devin/config.json (default mode is auto). + case ports.PermissionModeAcceptEdits: + // Devin has no dedicated accept-edits flag; auto prompts for writes, + // which is the safest non-default mapping. + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--permission-mode", "dangerous") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/devin/devin_test.go b/backend/internal/adapters/agent/devin/devin_test.go new file mode 100644 index 0000000..4510c7d --- /dev/null +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -0,0 +1,274 @@ +package devin + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "devin" { + t.Fatalf("ID = %q, want devin", m.ID) + } + if m.Name != "Devin" { + t.Fatalf("Name = %q", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetConfigSpecCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (&Plugin{}).GetConfigSpec(ctx); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", s) + } +} + +func TestGetLaunchCommandBypass(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "dangerous", "-p", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "fix it", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "-p", "fix it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + if strings.Contains(strings.Join(cmd, " "), "permission-mode") { + t.Fatal("should not have --permission-mode for default perms") + } +} + +func TestGetLaunchCommandAcceptEdits(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "refactor auth", + Permissions: ports.PermissionModeAcceptEdits, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "auto", "-p", "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandAuto(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "ship it", + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "auto", "-p", "ship it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandNoPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "x"}); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-abc123", + }, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"devin", "--permission-mode", "dangerous", "-r", "sess-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetRestoreCommandWhitespaceID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: " ", + }}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with whitespace agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "devin-ses-1", + devinTitleMetadataKey: "Fix login redirect", + devinSummaryMetadataKey: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "devin-ses-1" { + t.Fatalf("AgentSessionID = %q, want devin-ses-1", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestGetAgentHooksDelegates(t *testing.T) { + // We don't exercise the full hook merge here (claude tests cover it); + // just ensure it doesn't blow up on a temp workspace and that the + // method is wired (real hook install is exercised via claude delegation). + plugin := &Plugin{resolvedBinary: "devin"} + ws := t.TempDir() + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "devin-test-1", + }); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } +} + +func TestGetAgentHooksCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestResolveDevinBinaryFallback(t *testing.T) { + // When devin is not on PATH or well-known locations, fall back to the bare + // name so exec can still attempt to launch it. + bin, err := ResolveDevinBinary(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if bin == "" { + t.Fatal("empty binary path") + } +} + +func TestResolveDevinBinaryCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolveDevinBinary(ctx); err == nil { + t.Fatal("expected ctx error, got nil") + } +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 778a2fa..a93ba4e 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -17,6 +17,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/crush" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/devin" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" @@ -47,6 +48,7 @@ func Constructors() []adapters.Adapter { goose.New(), auggie.New(), continueagent.New(), + devin.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index c402fcd..5eeda64 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -105,6 +105,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessGoose, "goose"}, {domain.HarnessAuggie, "auggie"}, {domain.HarnessContinue, "continue"}, + {domain.HarnessDevin, "devin"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)