Files
kandev/apps/backend/internal/agent/agents/opencode_acp.go
T
Jelloeater's Agent 65f27ecbff fix(agents): launch opencode-acp binary-first (native binary on PATH), don't break on stale npm cache (#3836)
Co-authored-by: goose <goose@aaif.dev>
Co-authored-by: Benjamin <benjaminchamwb@gmail.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
Co-authored-by: João Salavisa <joao.salavisa@gmail.com>
2026-09-21 23:20:43 +01:00

217 lines
7.3 KiB
Go

package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/opencode_light.svg
var opencodeACPLogoLight []byte
//go:embed logos/opencode_dark.svg
var opencodeACPLogoDark []byte
const opencodeACPPackage = "opencode-ai"
// opencodeNativeBinary is the standalone opencode CLI. When it is installed on
// PATH we launch it directly — mirroring CodeNomad's binary-first launch — so
// agent startups and refreshes never depend on per-launch `npx --prefer-
// offline` resolution (see ManagedNPMRuntimeSpec.NativeBinary).
const opencodeNativeBinary = "opencode"
var (
_ Agent = (*OpenCodeACP)(nil)
_ PassthroughAgent = (*OpenCodeACP)(nil)
_ InferenceAgent = (*OpenCodeACP)(nil)
_ HostUtilityInferenceAgent = (*OpenCodeACP)(nil)
_ ManagedNPMRuntimeAgent = (*OpenCodeACP)(nil)
_ NativeBinaryAgent = (*OpenCodeACP)(nil)
)
// OpenCodeACP is the ACP protocol variant of OpenCode.
// Uses JSON-RPC 2.0 over stdin/stdout via "opencode acp" instead of REST/SSE.
type OpenCodeACP struct {
StandardPassthrough
}
func NewOpenCodeACP() *OpenCodeACP {
return &OpenCodeACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("opencode"),
ModelFlag: NewParam("--model", "{model}"),
PromptFlag: NewParam("--prompt", "{prompt}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
ResumeFlag: NewParam("-c"),
// opencode has no MCP flag; write a temp opencode.json and point
// it there via the OPENCODE_CONFIG env var (merges, never writes
// ~/.config/opencode).
MCPStrategy: mcpconfig.OpenCodeStrategy{},
},
},
}
}
func (a *OpenCodeACP) ID() string { return "opencode-acp" }
func (a *OpenCodeACP) Name() string { return "OpenCode AI Agent (ACP)" }
func (a *OpenCodeACP) DisplayName() string { return "OpenCode" }
func (a *OpenCodeACP) Description() string {
return "OpenCode coding agent using ACP protocol over stdin/stdout."
}
func (a *OpenCodeACP) Enabled() bool { return true }
func (a *OpenCodeACP) DisplayOrder() int { return 4 }
func (a *OpenCodeACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return opencodeACPLogoDark
}
return opencodeACPLogoLight
}
func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// Check for the opencode CLI on PATH. Any installed version is eligible
// for the ACP probe; auth and protocol compatibility are surfaced there.
result, err := Detect(ctx, WithCommand("opencode"))
if err != nil || !result.Available {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
// NativeBinaryName returns the standalone opencode CLI name probed for in the
// execution environment. See NativeBinaryAgent.
func (a *OpenCodeACP) NativeBinaryName() string { return opencodeNativeBinary }
func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
// Prefer the standalone opencode binary when the lifecycle probe found it
// on PATH. The managed npm runtime remains the fallback for containerized
// and remote runtimes.
if opts.PreferNativeBinary {
return a.ManagedNPMRuntime().NativeCommand()
}
return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion)
}
func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{
Package: opencodeACPPackage,
DefaultVersion: MustDefaultManagedNPMRuntimeVersion(opencodeACPPackage),
ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"},
NativeBinary: opencodeNativeBinary,
}
}
func (a *OpenCodeACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: a.ManagedNPMRuntime().CachedACPCommand(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
UserSkillDir: ".config/opencode/skills",
// opencode acp runs its HTTP server + MCP child tree alongside the
// ACP stdin/stdout. Closing stdin doesn't terminate the process, so
// skip the graceful wait and reap its process group immediately.
// See GH issue #1247.
RequiresProcessKill: true,
SessionConfig: SessionConfig{
NativeSessionResume: true,
NewSessionOnWorkspaceRebind: true,
CanRecover: &canRecover,
// Auth lives under .local/share/opencode and configuration under
// .config/opencode. Mount the isolated executor home so both trees
// remain visible without mounting the host home.
SessionDirTemplate: "{home}",
SessionDirTarget: "/root",
},
}
}
func (a *OpenCodeACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: remoteAuthMethodTypeFiles,
Label: remoteAuthLabelCopyFiles,
FileConflictPolicy: RemoteAuthFileConflictPolicyMergeJSONObject,
SourceFiles: map[string][]string{
"darwin": {".local/share/opencode/auth.json"},
"linux": {".local/share/opencode/auth.json"},
},
TargetRelDir: ".local/share/opencode",
},
},
}
}
func (a *OpenCodeACP) PortableConfig() *PortableConfig {
return &PortableConfig{Bundles: []PortableConfigBundle{
{
ID: "opencode.config",
Label: "Copy OpenCode configuration",
Files: []PortableConfigFile{
{SourcePaths: map[string]string{
"darwin": ".config/opencode/opencode.json",
"linux": ".config/opencode/opencode.json",
"windows": ".config/opencode/opencode.json",
}, TargetPath: ".config/opencode/opencode.json"},
{SourcePaths: map[string]string{
"darwin": ".config/opencode/opencode.jsonc",
"linux": ".config/opencode/opencode.jsonc",
"windows": ".config/opencode/opencode.jsonc",
}, TargetPath: ".config/opencode/opencode.jsonc"},
},
},
}}
}
func (a *OpenCodeACP) InstallScript() string {
return "npm install -g " + opencodeACPPackage
}
func (a *OpenCodeACP) BillingType() usage.BillingType { return defaultBillingType() }
func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
// InferenceConfig returns the executor-safe configuration for one-shot
// inference. Session inference can run inside a container or on SSH, so it
// keeps the managed npm command unless the executor selects native use.
func (a *OpenCodeACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: a.ManagedNPMRuntime().CachedACPCommand(),
}
}
// HostUtilityInferenceConfig prefers the native binary because host utility
// instances run on the backend host and do not cross an executor boundary.
func (a *OpenCodeACP) HostUtilityInferenceConfig() *InferenceConfig {
spec := a.ManagedNPMRuntime()
command := spec.CachedACPCommand()
if spec.NativeBinaryOnPath() {
command = spec.NativeCommand()
}
return &InferenceConfig{
Supported: true,
Command: command,
}
}