feat(backend): add 10 ACP agents, set_config_option, auth_required flow (#807)

This commit is contained in:
Carlos Florêncio
2026-05-04 07:46:56 +01:00
committed by GitHub
parent a77df053a6
commit 7ce2e24150
46 changed files with 2057 additions and 29 deletions
+20 -10
View File
@@ -29,7 +29,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
## Features
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
@@ -54,15 +54,25 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb
## Supported ACP Agents
| Agent | Protocol |
| Agent | Launch |
|:-------:|:----------:|
| **Claude Code** | ACP (`@zed-industries/claude-agent-acp`) |
| **Codex** | ACP (`@zed-industries/codex-acp`) |
| **GitHub Copilot** | ACP |
| **Gemini CLI** | ACP |
| **Amp** | ACP (`amp-acp`) |
| **Auggie** | ACP |
| **OpenCode** | ACP |
| **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp` |
| **Codex** | `npx -y @zed-industries/codex-acp` |
| **GitHub Copilot** | `npx -y @github/copilot --acp` |
| **Gemini CLI** | `npx -y @google/gemini-cli --acp` |
| **Amp** | `npx -y amp-acp` |
| **Auggie** | `npx -y @augmentcode/auggie --acp` |
| **OpenCode** | `opencode acp` |
| **Cursor** | `cursor-agent acp` *(requires Cursor Pro)* |
| **Qwen** | `npx -y @qwen-code/qwen-code --acp` |
| **Factory Droid** | `npx -y droid exec --output-format acp` |
| **iFlow (beta)** | `npx -y @iflow-ai/iflow-cli --experimental-acp` |
| **Kilocode** | `npx -y @kilocode/cli acp` |
| **Pi** | `npx -y pi-acp` |
| **Kimi** | `kimi acp` *(install Kimi CLI from Moonshot AI)* |
| **Kiro** | `kiro-cli-chat acp` *(install Kiro CLI from AWS)* |
| **Qoder** | `qodercli --acp` *(install Qoder CLI)* |
| **Trae** | `traecli acp serve` *(install Trae IDE CLI)* |
> All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is also available for direct terminal interaction with any agent CLI. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/add-agent-cli.md) for a step-by-step guide.
@@ -186,7 +196,7 @@ There are a few similar tools in this space, and new ones appearing everyday. He
- **Server-first architecture** - Not a desktop app. Runs as a server you can access from any device, including your phone. Start a task away from your computer and check in on it later.
- **Remote runtimes** - Run agents on remote servers and Docker hosts, not just your local machine.
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, and OpenCode side by side. Not locked to one vendor.
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, and Trae side by side. Not locked to one vendor.
- **CLI passthrough and chat** - Interact with agents through structured chat messages or drop into raw CLI mode for full agent TUI capabilities.
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
@@ -0,0 +1,113 @@
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/cursor_acp_light.svg
var cursorACPLogoLight []byte
//go:embed logos/cursor_acp_dark.svg
var cursorACPLogoDark []byte
const cursorACPBin = "cursor-agent"
var (
_ Agent = (*CursorACP)(nil)
_ PassthroughAgent = (*CursorACP)(nil)
_ InferenceAgent = (*CursorACP)(nil)
)
// CursorACP implements Agent for Cursor's CLI via its native ACP mode.
// Cursor isn't published to npm — users must install the cursor-agent binary
// from Cursor (Pro subscription required).
type CursorACP struct {
StandardPassthrough
}
func NewCursorACP() *CursorACP {
return &CursorACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(cursorACPBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *CursorACP) ID() string { return "cursor-acp" }
func (a *CursorACP) Name() string { return "Cursor ACP Agent" }
func (a *CursorACP) DisplayName() string { return "Cursor" }
func (a *CursorACP) Description() string {
return "Cursor CLI coding agent (cursor-agent) using the ACP protocol. Requires a Cursor Pro subscription."
}
func (a *CursorACP) Enabled() bool { return true }
func (a *CursorACP) DisplayOrder() int { return 13 }
func (a *CursorACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return cursorACPLogoDark
}
return cursorACPLogoLight
}
func (a *CursorACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(cursorACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *CursorACP) BuildCommand(opts CommandOptions) Command {
return Cmd(cursorACPBin, "acp").Build()
}
func (a *CursorACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(cursorACPBin, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.cursor",
},
}
}
func (a *CursorACP) RemoteAuth() *RemoteAuth { return nil }
func (a *CursorACP) InstallScript() string {
return "Install Cursor CLI from https://cursor.com/cli"
}
func (a *CursorACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *CursorACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(cursorACPBin, "acp"),
}
}
@@ -40,6 +40,35 @@ func WithCommand(name string) DetectOption {
}
}
// WithNpxRunnable reports the agent as runnable via `npx -y <pkg>` whenever
// npx is on PATH. MatchedPath is tagged "npx <pkg>" so the settings page
// renders "Detected at npx <pkg>", giving users an honest hint that the
// package isn't globally installed but will be fetched on launch.
//
// Only `npx` is checked, not `node`: every npm-distributed agent's launch
// command is `npx -y <pkg>`, so a node-only setup would pass detection but
// fail at session start with "exec: npx: executable file not found in PATH".
//
// Use this AFTER a WithCommand check for the global binary — Detect is
// first-match-wins, so the global install (if present) is preferred and
// reports its real path.
//
// NOTE: not currently wired into any agent's IsInstalled. The host-utility
// manager treats Available=true as a green light to spawn and probe the
// agent at boot, so claiming availability via npx alone caused unwanted
// downloads and misleading auth_required/failed states for agents the user
// never installed. The helper is parked here until the host-utility manager
// gains a "skip probe for npx-fallback detections" path; at that point this
// can be re-wired into the npm-distributed agents' IsInstalled.
func WithNpxRunnable(pkg string) DetectOption {
return func(ctx context.Context) (bool, string, error) {
if _, err := exec.LookPath("npx"); err == nil {
return true, "npx " + pkg, nil
}
return false, "", nil
}
}
// WithCommandOutput runs a command and checks stdout matches regex.
func WithCommandOutput(pattern string, name string, args ...string) DetectOption {
return func(ctx context.Context) (bool, string, error) {
@@ -0,0 +1,86 @@
package agents
import (
"context"
"os/exec"
"strings"
"testing"
)
// TestWithNpxRunnable_MatchesWhenNpxOnPath confirms the npx-fallback returns
// available with the "npx <pkg>" tag whenever npx is on PATH. The settings
// page renders this as "Detected at npx <pkg>", giving users a truthful hint
// that the package isn't globally installed but launches via npx -y.
func TestWithNpxRunnable_MatchesWhenNpxOnPath(t *testing.T) {
if !npxOnPath() {
t.Skip("npx not on PATH; skipping fallback test")
}
found, matched, err := WithNpxRunnable("@example/pkg")(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !found {
t.Fatalf("expected found=true when node/npx is on PATH")
}
if matched != "npx @example/pkg" {
t.Errorf("matched = %q, want %q", matched, "npx @example/pkg")
}
}
// TestDetect_PrefersGlobalBinaryOverNpxFallback pins the first-match-wins
// contract: when both the global binary and npx are available, the global
// install reports its real path. This is what makes the UI's "Detected at
// /usr/local/bin/<bin>" hint accurate for users who actually installed.
func TestDetect_PrefersGlobalBinaryOverNpxFallback(t *testing.T) {
if !npxOnPath() {
t.Skip("npx not on PATH; skipping ordering test")
}
// `ls` is guaranteed on PATH in any POSIX-ish CI/dev env.
if _, err := exec.LookPath("ls"); err != nil {
t.Skip("ls not on PATH; skipping")
}
result, err := Detect(context.Background(),
WithCommand("ls"),
WithNpxRunnable("@example/pkg"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Available {
t.Fatalf("expected Available=true")
}
if strings.HasPrefix(result.MatchedPath, "npx ") {
t.Errorf("MatchedPath = %q, want a real path (global ls), not the npx tag", result.MatchedPath)
}
}
// TestDetect_FallsBackToNpxWhenGlobalMissing covers the headline case: the
// agent's binary isn't installed globally, but node is — Detect should still
// return Available=true with the npx tag so the agent shows as "Installed"
// on the settings page.
func TestDetect_FallsBackToNpxWhenGlobalMissing(t *testing.T) {
if !npxOnPath() {
t.Skip("npx not on PATH; skipping fallback ordering test")
}
result, err := Detect(context.Background(),
WithCommand("definitely-not-a-real-binary-xyz"),
WithNpxRunnable("@example/pkg"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Available {
t.Fatalf("expected Available=true via npx fallback")
}
if result.MatchedPath != "npx @example/pkg" {
t.Errorf("MatchedPath = %q, want %q", result.MatchedPath, "npx @example/pkg")
}
}
func npxOnPath() bool {
_, err := exec.LookPath("npx")
return err == nil
}
@@ -0,0 +1,114 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/droid_acp_light.svg
var droidACPLogoLight []byte
//go:embed logos/droid_acp_dark.svg
var droidACPLogoDark []byte
const droidACPPkg = "droid"
var (
_ Agent = (*DroidACP)(nil)
_ PassthroughAgent = (*DroidACP)(nil)
_ InferenceAgent = (*DroidACP)(nil)
)
// DroidACP implements Agent for Factory's Droid CLI using ACP.
//
// Factory's public CLI reference documents `droid exec` as "single-shot
// execution" mode and does not list `acp` as a valid `--output-format` value.
// We rely on the same invocation acpx uses (`droid exec --output-format acp`).
// Native session resume is intentionally disabled until we can verify the
// process stays alive across multiple session/prompt calls — if it doesn't,
// our fork-session fallback handles continuity by re-invoking with -s.
type DroidACP struct {
StandardPassthrough
}
func NewDroidACP() *DroidACP {
return &DroidACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", droidACPPkg),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *DroidACP) ID() string { return "droid-acp" }
func (a *DroidACP) Name() string { return "Factory Droid ACP Agent" }
func (a *DroidACP) DisplayName() string { return "Droid" }
func (a *DroidACP) Description() string {
return "Factory.ai Droid coding agent via `droid exec --output-format acp`."
}
func (a *DroidACP) Enabled() bool { return true }
func (a *DroidACP) DisplayOrder() int { return 10 }
func (a *DroidACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return droidACPLogoDark
}
return droidACPLogoLight
}
func (a *DroidACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand("droid"))
if err != nil {
return result, err
}
result.SupportsMCP = true
return result, nil
}
func (a *DroidACP) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", droidACPPkg, "exec", "--output-format", "acp").Build()
}
func (a *DroidACP) Runtime() *RuntimeConfig {
canRecover := false
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", droidACPPkg, "exec", "--output-format", "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: false,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.factory",
},
}
}
func (a *DroidACP) RemoteAuth() *RemoteAuth { return nil }
func (a *DroidACP) InstallScript() string {
return "npm install -g " + droidACPPkg
}
func (a *DroidACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *DroidACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand("npx", "-y", droidACPPkg, "exec", "--output-format", "acp"),
}
}
@@ -0,0 +1,108 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/iflow_acp_light.svg
var iflowACPLogoLight []byte
//go:embed logos/iflow_acp_dark.svg
var iflowACPLogoDark []byte
const iflowACPPkg = "@iflow-ai/iflow-cli"
var (
_ Agent = (*IFlowACP)(nil)
_ PassthroughAgent = (*IFlowACP)(nil)
_ InferenceAgent = (*IFlowACP)(nil)
)
// IFlowACP implements Agent for the iFlow CLI using its experimental ACP mode.
// The upstream flag is `--experimental-acp`; treat this agent as beta until
// iFlow stabilises the protocol.
type IFlowACP struct {
StandardPassthrough
}
func NewIFlowACP() *IFlowACP {
return &IFlowACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", iflowACPPkg),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *IFlowACP) ID() string { return "iflow-acp" }
func (a *IFlowACP) Name() string { return "iFlow ACP Agent" }
func (a *IFlowACP) DisplayName() string { return "iFlow (beta)" }
func (a *IFlowACP) Description() string {
return "iFlow coding agent using ACP via the experimental --experimental-acp flag."
}
func (a *IFlowACP) Enabled() bool { return true }
func (a *IFlowACP) DisplayOrder() int { return 9 }
func (a *IFlowACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return iflowACPLogoDark
}
return iflowACPLogoLight
}
func (a *IFlowACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand("iflow"))
if err != nil {
return result, err
}
result.SupportsMCP = true
return result, nil
}
func (a *IFlowACP) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", iflowACPPkg, "--experimental-acp").Build()
}
func (a *IFlowACP) Runtime() *RuntimeConfig {
canRecover := false
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", iflowACPPkg, "--experimental-acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.iflow",
},
}
}
func (a *IFlowACP) RemoteAuth() *RemoteAuth { return nil }
func (a *IFlowACP) InstallScript() string {
return "npm install -g " + iflowACPPkg
}
func (a *IFlowACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *IFlowACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand("npx", "-y", iflowACPPkg, "--experimental-acp"),
}
}
@@ -0,0 +1,110 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/kilocode_acp_light.svg
var kilocodeACPLogoLight []byte
//go:embed logos/kilocode_acp_dark.svg
var kilocodeACPLogoDark []byte
const kilocodeACPPkg = "@kilocode/cli"
var (
_ Agent = (*KilocodeACP)(nil)
_ PassthroughAgent = (*KilocodeACP)(nil)
_ InferenceAgent = (*KilocodeACP)(nil)
)
// KilocodeACP implements Agent for Kilocode using ACP.
type KilocodeACP struct {
StandardPassthrough
}
func NewKilocodeACP() *KilocodeACP {
return &KilocodeACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", kilocodeACPPkg),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *KilocodeACP) ID() string { return "kilocode-acp" }
func (a *KilocodeACP) Name() string { return "Kilocode ACP Agent" }
func (a *KilocodeACP) DisplayName() string { return "Kilocode" }
func (a *KilocodeACP) Description() string {
return "Kilocode coding agent using the ACP protocol over stdin/stdout."
}
func (a *KilocodeACP) Enabled() bool { return true }
func (a *KilocodeACP) DisplayOrder() int { return 11 }
func (a *KilocodeACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return kilocodeACPLogoDark
}
return kilocodeACPLogoLight
}
func (a *KilocodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand("kilo"), WithCommand("kilocode"))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *KilocodeACP) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", kilocodeACPPkg, "acp").Build()
}
func (a *KilocodeACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", kilocodeACPPkg, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.kilocode",
},
}
}
func (a *KilocodeACP) RemoteAuth() *RemoteAuth { return nil }
func (a *KilocodeACP) InstallScript() string {
return "npm install -g " + kilocodeACPPkg
}
func (a *KilocodeACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *KilocodeACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand("npx", "-y", kilocodeACPPkg, "acp"),
}
}
@@ -0,0 +1,112 @@
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/kimi_acp_light.svg
var kimiACPLogoLight []byte
//go:embed logos/kimi_acp_dark.svg
var kimiACPLogoDark []byte
const kimiACPBin = "kimi"
var (
_ Agent = (*KimiACP)(nil)
_ PassthroughAgent = (*KimiACP)(nil)
_ InferenceAgent = (*KimiACP)(nil)
)
// KimiACP implements Agent for Moonshot's Kimi CLI using ACP.
// Not on npm — users must install the kimi binary from Moonshot AI.
type KimiACP struct {
StandardPassthrough
}
func NewKimiACP() *KimiACP {
return &KimiACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(kimiACPBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *KimiACP) ID() string { return "kimi-acp" }
func (a *KimiACP) Name() string { return "Kimi ACP Agent" }
func (a *KimiACP) DisplayName() string { return "Kimi" }
func (a *KimiACP) Description() string {
return "Moonshot AI Kimi coding agent using the ACP protocol over stdin/stdout."
}
func (a *KimiACP) Enabled() bool { return true }
func (a *KimiACP) DisplayOrder() int { return 14 }
func (a *KimiACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return kimiACPLogoDark
}
return kimiACPLogoLight
}
func (a *KimiACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(kimiACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *KimiACP) BuildCommand(opts CommandOptions) Command {
return Cmd(kimiACPBin, "acp").Build()
}
func (a *KimiACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(kimiACPBin, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.kimi",
},
}
}
func (a *KimiACP) RemoteAuth() *RemoteAuth { return nil }
func (a *KimiACP) InstallScript() string {
return "Install Kimi CLI from Moonshot AI"
}
func (a *KimiACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *KimiACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(kimiACPBin, "acp"),
}
}
@@ -0,0 +1,117 @@
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/kiro_acp_light.svg
var kiroACPLogoLight []byte
//go:embed logos/kiro_acp_dark.svg
var kiroACPLogoDark []byte
const kiroACPBin = "kiro-cli-chat"
var (
_ Agent = (*KiroACP)(nil)
_ PassthroughAgent = (*KiroACP)(nil)
_ InferenceAgent = (*KiroACP)(nil)
)
// KiroACP implements Agent for AWS Kiro using ACP. The CLI binary
// (kiro-cli-chat) is installed via AWS-provided tooling.
type KiroACP struct {
StandardPassthrough
}
func NewKiroACP() *KiroACP {
return &KiroACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(kiroACPBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *KiroACP) ID() string { return "kiro-acp" }
func (a *KiroACP) Name() string { return "Kiro ACP Agent" }
func (a *KiroACP) DisplayName() string { return "Kiro" }
func (a *KiroACP) Description() string {
return "AWS Kiro coding agent using the ACP protocol via kiro-cli-chat."
}
func (a *KiroACP) Enabled() bool { return true }
func (a *KiroACP) DisplayOrder() int { return 15 }
func (a *KiroACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return kiroACPLogoDark
}
return kiroACPLogoLight
}
func (a *KiroACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(kiroACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *KiroACP) BuildCommand(opts CommandOptions) Command {
return Cmd(kiroACPBin, "acp").Build()
}
func (a *KiroACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(kiroACPBin, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Kiro session dir is
// confirmed (likely "{home}/.kiro"). Without it, the Docker
// runtime skips mounting the session dir, so NativeSessionResume
// has no persistence across container restarts. ACP-native
// session/load still works in standalone mode where the host
// home dir is the working filesystem.
NativeSessionResume: true,
CanRecover: &canRecover,
},
}
}
func (a *KiroACP) RemoteAuth() *RemoteAuth { return nil }
func (a *KiroACP) InstallScript() string {
return "Install Kiro CLI from AWS"
}
func (a *KiroACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *KiroACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(kiroACPBin, "acp"),
}
}
@@ -0,0 +1 @@
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1 @@
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"></path></svg>

After

Width:  |  Height:  |  Size: 652 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"></path></svg>

After

Width:  |  Height:  |  Size: 652 B

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003" xml:space="preserve" version="1.0" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="2589.93 529.3 4225.03 4225.03">
<defs>
<style type="text/css">
<![CDATA[
.fil3 {fill:#2B2A29}
.fil5 {fill:#0179FF}
.fil4 {fill:white}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<rect class="fil3" x="2589.93" y="529.3" width="4225.03" height="4225.03" rx="304.59" ry="304.59"/>
<g id="_2448169161120">
<path class="fil4" d="M4354.82 2395.15l-641.29 0 0 -1043.67 -518.69 0 0 2700.35 518.69 0 0 -1137.99 930.5 0c99.31,0 187.16,-42.51 257.02,-104.49 29.17,-25.87 43.76,-42.09 67.45,-77.16l27.62 -44.68 0 1364.32 521.84 0 0 -1128.55c0,-275.9 -193.35,-483.68 -441.87,-523.22 -53.14,-8.46 -250.95,-4.91 -318.89,-4.91 25.23,-21.93 133.44,-28.86 263.21,-208.33 8.03,-11.11 13.21,-21.99 19.88,-33.56 23.63,-40.99 81.8,-173.93 101.34,-219.31l114.36 -256.58c27.34,-56.91 56.36,-131.98 81.76,-182.31 6.16,-12.22 11.49,-25.16 16.69,-36.74 8.37,-18.63 46,-98.65 47.21,-113.12l-578.42 0c-10.76,20.34 -18.77,42.96 -29.35,64.97 -5.26,10.95 -10.16,23.06 -14.21,32.93l-390.21 889.24c-8.43,18.76 -23.03,62.81 -34.64,62.81z"/>
<path class="fil5" d="M5624.83 1533.81c0,162.36 81.74,224.33 81.74,242.06 0,23.25 -11.97,27.5 -32.81,55.21 -11.61,15.44 -24.68,29.28 -33.21,45.39l81.58 -3.31c11.75,-0.97 10.98,-2.67 25.21,-3.08l81.85 -3.04c13.69,-0.7 11.45,-2.49 25.14,-3.15 93.1,-4.5 197.72,-15.93 261.76,-77.75 118.61,-114.51 125.67,-360.29 17.22,-468.46 -45.13,-45.01 -112.6,-81.03 -178.4,-82.52 -17.05,-0.39 -19.63,-3.34 -34.58,-3.34 -29.74,0 -98.29,11.16 -125.81,22.15 -34.4,13.73 -66.41,32.15 -91.97,58.92 -40.84,42.79 -77.72,137.66 -77.72,220.92z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003" xml:space="preserve" version="1.0" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="2589.93 529.3 4225.03 4225.03">
<defs>
<style type="text/css">
<![CDATA[
.fil3 {fill:#2B2A29}
.fil5 {fill:#0179FF}
.fil4 {fill:white}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<rect class="fil3" x="2589.93" y="529.3" width="4225.03" height="4225.03" rx="304.59" ry="304.59"/>
<g id="_2448169161120">
<path class="fil4" d="M4354.82 2395.15l-641.29 0 0 -1043.67 -518.69 0 0 2700.35 518.69 0 0 -1137.99 930.5 0c99.31,0 187.16,-42.51 257.02,-104.49 29.17,-25.87 43.76,-42.09 67.45,-77.16l27.62 -44.68 0 1364.32 521.84 0 0 -1128.55c0,-275.9 -193.35,-483.68 -441.87,-523.22 -53.14,-8.46 -250.95,-4.91 -318.89,-4.91 25.23,-21.93 133.44,-28.86 263.21,-208.33 8.03,-11.11 13.21,-21.99 19.88,-33.56 23.63,-40.99 81.8,-173.93 101.34,-219.31l114.36 -256.58c27.34,-56.91 56.36,-131.98 81.76,-182.31 6.16,-12.22 11.49,-25.16 16.69,-36.74 8.37,-18.63 46,-98.65 47.21,-113.12l-578.42 0c-10.76,20.34 -18.77,42.96 -29.35,64.97 -5.26,10.95 -10.16,23.06 -14.21,32.93l-390.21 889.24c-8.43,18.76 -23.03,62.81 -34.64,62.81z"/>
<path class="fil5" d="M5624.83 1533.81c0,162.36 81.74,224.33 81.74,242.06 0,23.25 -11.97,27.5 -32.81,55.21 -11.61,15.44 -24.68,29.28 -33.21,45.39l81.58 -3.31c11.75,-0.97 10.98,-2.67 25.21,-3.08l81.85 -3.04c13.69,-0.7 11.45,-2.49 25.14,-3.15 93.1,-4.5 197.72,-15.93 261.76,-77.75 118.61,-114.51 125.67,-360.29 17.22,-468.46 -45.13,-45.01 -112.6,-81.03 -178.4,-82.52 -17.05,-0.39 -19.63,-3.34 -34.58,-3.34 -29.74,0 -98.29,11.16 -125.81,22.15 -34.4,13.73 -66.41,32.15 -91.97,58.92 -40.84,42.79 -77.72,137.66 -77.72,220.92z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>

After

Width:  |  Height:  |  Size: 133 B

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
<path fill="#fff" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<!-- i dot -->
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
<path fill="#000000" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<!-- i dot -->
<path fill="#000000" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
<defs>
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
<defs>
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>

After

Width:  |  Height:  |  Size: 377 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>

After

Width:  |  Height:  |  Size: 377 B

@@ -0,0 +1,265 @@
package agents
import (
"context"
"os/exec"
"slices"
"strings"
"testing"
"github.com/kandev/kandev/pkg/agent"
)
// acpAgentSpec captures the full contract each newly-added ACP agent must
// honor. Pinning every command surface (BuildCommand, Runtime.Cmd,
// InferenceConfig.Command, PassthroughCmd) keeps regressions like one path
// drifting to npx while the rest stay native (or vice versa) loud rather
// than silent.
type acpAgentSpec struct {
id string
displayName string
// detectBinaries lists every binary name IsInstalled accepts via
// WithCommand. Used by TestNewACPAgents_DetectionRequiresGlobalBinary
// to skip the test whenever ANY of these is on PATH — otherwise an
// agent with multiple WithCommand fallbacks (e.g. Pi accepts both
// `pi-acp` and `pi`) flakes when the secondary binary is present.
detectBinaries []string
expectedArgv []string // BuildCommand and Runtime.Cmd
inferenceArgv []string // InferenceConfig.Command
passthroughArgv []string // PassthroughCmd (zero-args allowed)
installViaNpm bool // InstallScript starts with "npm install -g"
}
var newACPAgentSpecs = []struct {
new func() Agent
spec acpAgentSpec
}{
{func() Agent { return NewQwenACP() }, acpAgentSpec{
id: "qwen-acp", displayName: "Qwen", detectBinaries: []string{"qwen"},
expectedArgv: []string{"npx", "-y", "@qwen-code/qwen-code", "--acp"},
inferenceArgv: []string{"npx", "-y", "@qwen-code/qwen-code", "--acp"},
passthroughArgv: []string{"npx", "-y", "@qwen-code/qwen-code"},
installViaNpm: true,
}},
{func() Agent { return NewIFlowACP() }, acpAgentSpec{
id: "iflow-acp", displayName: "iFlow (beta)", detectBinaries: []string{"iflow"},
expectedArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli", "--experimental-acp"},
inferenceArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli", "--experimental-acp"},
passthroughArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli"},
installViaNpm: true,
}},
{func() Agent { return NewDroidACP() }, acpAgentSpec{
id: "droid-acp", displayName: "Droid", detectBinaries: []string{"droid"},
expectedArgv: []string{"npx", "-y", "droid", "exec", "--output-format", "acp"},
inferenceArgv: []string{"npx", "-y", "droid", "exec", "--output-format", "acp"},
passthroughArgv: []string{"npx", "-y", "droid"},
installViaNpm: true,
}},
{func() Agent { return NewKilocodeACP() }, acpAgentSpec{
id: "kilocode-acp", displayName: "Kilocode", detectBinaries: []string{"kilo", "kilocode"},
expectedArgv: []string{"npx", "-y", "@kilocode/cli", "acp"},
inferenceArgv: []string{"npx", "-y", "@kilocode/cli", "acp"},
passthroughArgv: []string{"npx", "-y", "@kilocode/cli"},
installViaNpm: true,
}},
{func() Agent { return NewPiACP() }, acpAgentSpec{
id: "pi-acp", displayName: "Pi", detectBinaries: []string{"pi-acp", "pi"},
expectedArgv: []string{"npx", "-y", "pi-acp"},
inferenceArgv: []string{"npx", "-y", "pi-acp"},
passthroughArgv: []string{"npx", "-y", "pi-acp"},
installViaNpm: true,
}},
{func() Agent { return NewCursorACP() }, acpAgentSpec{
id: "cursor-acp", displayName: "Cursor", detectBinaries: []string{"cursor-agent"},
expectedArgv: []string{"cursor-agent", "acp"},
inferenceArgv: []string{"cursor-agent", "acp"},
passthroughArgv: []string{"cursor-agent"},
installViaNpm: false,
}},
{func() Agent { return NewKimiACP() }, acpAgentSpec{
id: "kimi-acp", displayName: "Kimi", detectBinaries: []string{"kimi"},
expectedArgv: []string{"kimi", "acp"},
inferenceArgv: []string{"kimi", "acp"},
passthroughArgv: []string{"kimi"},
installViaNpm: false,
}},
{func() Agent { return NewKiroACP() }, acpAgentSpec{
id: "kiro-acp", displayName: "Kiro", detectBinaries: []string{"kiro-cli-chat"},
expectedArgv: []string{"kiro-cli-chat", "acp"},
inferenceArgv: []string{"kiro-cli-chat", "acp"},
passthroughArgv: []string{"kiro-cli-chat"},
installViaNpm: false,
}},
{func() Agent { return NewQoderACP() }, acpAgentSpec{
id: "qoder-acp", displayName: "Qoder", detectBinaries: []string{"qodercli"},
expectedArgv: []string{"qodercli", "--acp"},
inferenceArgv: []string{"qodercli", "--acp"},
passthroughArgv: []string{"qodercli"},
installViaNpm: false,
}},
{func() Agent { return NewTraeACP() }, acpAgentSpec{
id: "trae-acp", displayName: "Trae", detectBinaries: []string{"traecli"},
expectedArgv: []string{"traecli", "acp", "serve"},
inferenceArgv: []string{"traecli", "acp", "serve"},
passthroughArgv: []string{"traecli"},
installViaNpm: false,
}},
}
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
for _, tc := range newACPAgentSpecs {
t.Run(tc.spec.id, func(t *testing.T) {
ag := tc.new()
if got := ag.ID(); got != tc.spec.id {
t.Errorf("ID() = %q, want %q", got, tc.spec.id)
}
if got := ag.DisplayName(); got != tc.spec.displayName {
t.Errorf("DisplayName() = %q, want %q", got, tc.spec.displayName)
}
if !ag.Enabled() {
t.Errorf("Enabled() = false, want true")
}
})
}
}
// TestNewACPAgents_AllCommandSurfaces pins every launch path: BuildCommand,
// Runtime.Cmd, InferenceConfig.Command, and PassthroughCmd. Without this the
// PassthroughCmd field can drift independently of the others (see the
// OpenCode npx regression in this PR's review history).
func TestNewACPAgents_AllCommandSurfaces(t *testing.T) {
for _, tc := range newACPAgentSpecs {
t.Run(tc.spec.id, func(t *testing.T) {
ag := tc.new()
assertArgvEqual(t, "BuildCommand", ag.BuildCommand(CommandOptions{}).Args(), tc.spec.expectedArgv)
rt := ag.Runtime()
if rt == nil {
t.Fatalf("Runtime() returned nil")
}
if rt.Protocol != agent.ProtocolACP {
t.Errorf("Runtime.Protocol = %q, want ACP", rt.Protocol)
}
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), tc.spec.expectedArgv)
ia, ok := ag.(InferenceAgent)
if !ok {
t.Fatalf("%s does not implement InferenceAgent", tc.spec.id)
}
ic := ia.InferenceConfig()
if ic == nil || !ic.Supported {
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
}
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), tc.spec.inferenceArgv)
pa, ok := ag.(PassthroughAgent)
if !ok {
t.Fatalf("%s does not implement PassthroughAgent", tc.spec.id)
}
assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), tc.spec.passthroughArgv)
})
}
}
func TestNewACPAgents_InstallScript(t *testing.T) {
for _, tc := range newACPAgentSpecs {
t.Run(tc.spec.id, func(t *testing.T) {
ag := tc.new()
got := ag.InstallScript()
hasNpm := strings.HasPrefix(got, "npm install -g ")
if tc.spec.installViaNpm && !hasNpm {
t.Errorf("InstallScript() = %q, want npm install -g …", got)
}
if !tc.spec.installViaNpm && hasNpm {
t.Errorf("InstallScript() should NOT use npm for native-binary agent: %q", got)
}
// The primary detection binary must be referenced somewhere
// actionable — either argv (native binaries) or InstallScript
// (npm install -g <pkg> ships the bin), so users with "Available
// to Install" status see a hint that resolves to the right
// command. Only check the first detect binary; secondaries are
// fallbacks that may not appear in either surface.
primary := tc.spec.detectBinaries[0]
argv := ag.BuildCommand(CommandOptions{}).Args()
if !slices.Contains(argv, primary) && !strings.Contains(got, primary) {
t.Errorf("detection binary %q not referenced in argv (%v) or InstallScript (%q)",
primary, argv, got)
}
})
}
}
// TestNewACPAgents_DetectionRequiresGlobalBinary pins the contract that
// agents are not reported as available solely because npx is on PATH. The
// host-utility manager treats Available=true as a green light to spawn and
// probe the agent — claiming availability for an agent the user hasn't
// actually installed triggers unwanted package downloads and produces
// misleading auth_required/failed states for agents the user never asked
// to use. detectBinaries comes from the spec table so npx-launched agents
// (whose argv[0] is "npx") are still verified against their real detection
// targets (qwen, iflow, droid, …), and agents with multiple WithCommand
// fallbacks (e.g. Pi accepts both `pi-acp` and `pi`) skip the test
// whenever ANY of those is on PATH so the test doesn't flake on CI hosts
// that happen to have a generic `pi` (Raspberry Pi tooling) installed.
func TestNewACPAgents_DetectionRequiresGlobalBinary(t *testing.T) {
for _, tc := range newACPAgentSpecs {
t.Run(tc.spec.id, func(t *testing.T) {
for _, binary := range tc.spec.detectBinaries {
if _, err := exec.LookPath(binary); err == nil {
t.Skipf("detection binary %q is on PATH; can't verify availability requirement", binary)
}
}
result, err := tc.new().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled error: %v", err)
}
if result.Available {
t.Errorf("Available=true without any of %v on PATH; detection should require the global binary so the host-utility manager doesn't spawn unwanted npx probes",
tc.spec.detectBinaries)
}
})
}
}
// TestNewACPAgents_LogosNonEmpty guards against agents shipping with empty
// embedded SVGs (which renders as a broken <img> in the UI).
func TestNewACPAgents_LogosNonEmpty(t *testing.T) {
for _, tc := range newACPAgentSpecs {
t.Run(tc.spec.id, func(t *testing.T) {
ag := tc.new()
if len(ag.Logo(LogoLight)) == 0 {
t.Errorf("Logo(LogoLight) is empty")
}
if len(ag.Logo(LogoDark)) == 0 {
t.Errorf("Logo(LogoDark) is empty")
}
})
}
}
// TestNewACPAgents_DisplayOrderUnique ensures the new agents don't collide
// with each other or with the existing built-ins (Claude=1..Amp=7).
func TestNewACPAgents_DisplayOrderUnique(t *testing.T) {
all := []Agent{
NewClaudeACP(), NewCodexACP(), NewAuggie(), NewOpenCodeACP(),
NewGemini(), NewCopilotACP(), NewAmpACP(),
}
for _, tc := range newACPAgentSpecs {
all = append(all, tc.new())
}
seen := map[int]string{}
for _, ag := range all {
order := ag.DisplayOrder()
if other, exists := seen[order]; exists {
t.Errorf("DisplayOrder %d collision: %s and %s", order, other, ag.ID())
}
seen[order] = ag.ID()
}
}
func assertArgvEqual(t *testing.T, label string, got, want []string) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("%s argv mismatch\n got: %#v\n want: %#v", label, got, want)
}
}
@@ -0,0 +1,119 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/pi_acp_light.svg
var piACPLogoLight []byte
//go:embed logos/pi_acp_dark.svg
var piACPLogoDark []byte
const piACPPkg = "pi-acp"
var (
_ Agent = (*PiACP)(nil)
_ PassthroughAgent = (*PiACP)(nil)
_ InferenceAgent = (*PiACP)(nil)
)
// PiACP implements Agent for the Pi Coding Agent via the pi-acp adapter.
type PiACP struct {
StandardPassthrough
}
func NewPiACP() *PiACP {
return &PiACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", piACPPkg),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *PiACP) ID() string { return "pi-acp" }
func (a *PiACP) Name() string { return "Pi Coding Agent ACP" }
func (a *PiACP) DisplayName() string { return "Pi" }
func (a *PiACP) Description() string {
return "Pi Coding Agent using the ACP protocol via the pi-acp adapter."
}
func (a *PiACP) Enabled() bool { return true }
func (a *PiACP) DisplayOrder() int { return 12 }
func (a *PiACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return piACPLogoDark
}
return piACPLogoLight
}
func (a *PiACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// "pi" is short and could in theory collide with unrelated tooling, but
// the pi-mono coding agent (https://github.com/badlogic/pi-mono) ships
// its CLI as `pi`, and a kandev user with `pi` on PATH is overwhelmingly
// likely to be the pi coding-agent installation we're targeting.
// Checking `pi-acp` first lets a dedicated wrapper take precedence
// when both are present.
result, err := Detect(ctx, WithCommand("pi-acp"), WithCommand("pi"))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *PiACP) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", piACPPkg).Build()
}
func (a *PiACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", piACPPkg).Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Pi session dir is
// confirmed. Without it, the Docker runtime skips mounting the
// session dir, so NativeSessionResume has no persistence across
// container restarts.
NativeSessionResume: true,
CanRecover: &canRecover,
},
}
}
func (a *PiACP) RemoteAuth() *RemoteAuth { return nil }
func (a *PiACP) InstallScript() string {
return "npm install -g " + piACPPkg
}
func (a *PiACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *PiACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand("npx", "-y", piACPPkg),
}
}
@@ -0,0 +1,114 @@
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/qoder_acp_light.svg
var qoderACPLogoLight []byte
//go:embed logos/qoder_acp_dark.svg
var qoderACPLogoDark []byte
const qoderACPBin = "qodercli"
var (
_ Agent = (*QoderACP)(nil)
_ PassthroughAgent = (*QoderACP)(nil)
_ InferenceAgent = (*QoderACP)(nil)
)
// QoderACP implements Agent for the Qoder CLI using ACP.
type QoderACP struct {
StandardPassthrough
}
func NewQoderACP() *QoderACP {
return &QoderACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(qoderACPBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *QoderACP) ID() string { return "qoder-acp" }
func (a *QoderACP) Name() string { return "Qoder ACP Agent" }
func (a *QoderACP) DisplayName() string { return "Qoder" }
func (a *QoderACP) Description() string {
return "Qoder coding agent using the ACP protocol via qodercli --acp."
}
func (a *QoderACP) Enabled() bool { return true }
func (a *QoderACP) DisplayOrder() int { return 16 }
func (a *QoderACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return qoderACPLogoDark
}
return qoderACPLogoLight
}
func (a *QoderACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(qoderACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *QoderACP) BuildCommand(opts CommandOptions) Command {
return Cmd(qoderACPBin, "--acp").Build()
}
func (a *QoderACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(qoderACPBin, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Qoder session dir is
// confirmed. Without it, the Docker runtime skips mounting the
// session dir, so NativeSessionResume has no persistence across
// container restarts.
NativeSessionResume: true,
CanRecover: &canRecover,
},
}
}
func (a *QoderACP) RemoteAuth() *RemoteAuth { return nil }
func (a *QoderACP) InstallScript() string {
return "Install Qoder CLI from https://qoder.com"
}
func (a *QoderACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *QoderACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(qoderACPBin, "--acp"),
}
}
@@ -0,0 +1,110 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/qwen_acp_light.svg
var qwenACPLogoLight []byte
//go:embed logos/qwen_acp_dark.svg
var qwenACPLogoDark []byte
const qwenACPPkg = "@qwen-code/qwen-code"
var (
_ Agent = (*QwenACP)(nil)
_ PassthroughAgent = (*QwenACP)(nil)
_ InferenceAgent = (*QwenACP)(nil)
)
// QwenACP implements Agent for Alibaba's Qwen Code CLI using the ACP protocol.
type QwenACP struct {
StandardPassthrough
}
func NewQwenACP() *QwenACP {
return &QwenACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", qwenACPPkg),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *QwenACP) ID() string { return "qwen-acp" }
func (a *QwenACP) Name() string { return "Qwen Code ACP Agent" }
func (a *QwenACP) DisplayName() string { return "Qwen" }
func (a *QwenACP) Description() string {
return "Alibaba Qwen Code coding agent using the ACP protocol over stdin/stdout."
}
func (a *QwenACP) Enabled() bool { return true }
func (a *QwenACP) DisplayOrder() int { return 8 }
func (a *QwenACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return qwenACPLogoDark
}
return qwenACPLogoLight
}
func (a *QwenACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand("qwen"))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *QwenACP) BuildCommand(opts CommandOptions) Command {
return Cmd("npx", "-y", qwenACPPkg, "--acp").Build()
}
func (a *QwenACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", qwenACPPkg, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.qwen",
},
}
}
func (a *QwenACP) RemoteAuth() *RemoteAuth { return nil }
func (a *QwenACP) InstallScript() string {
return "npm install -g " + qwenACPPkg
}
func (a *QwenACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *QwenACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand("npx", "-y", qwenACPPkg, "--acp"),
}
}
@@ -0,0 +1,113 @@
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/trae_acp_light.svg
var traeACPLogoLight []byte
//go:embed logos/trae_acp_dark.svg
var traeACPLogoDark []byte
const traeACPBin = "traecli"
var (
_ Agent = (*TraeACP)(nil)
_ PassthroughAgent = (*TraeACP)(nil)
_ InferenceAgent = (*TraeACP)(nil)
)
// TraeACP implements Agent for ByteDance's Trae IDE CLI using ACP.
type TraeACP struct {
StandardPassthrough
}
func NewTraeACP() *TraeACP {
return &TraeACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(traeACPBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *TraeACP) ID() string { return "trae-acp" }
func (a *TraeACP) Name() string { return "Trae ACP Agent" }
func (a *TraeACP) DisplayName() string { return "Trae" }
func (a *TraeACP) Description() string {
return "ByteDance Trae IDE coding agent using the ACP protocol via traecli acp serve."
}
func (a *TraeACP) Enabled() bool { return true }
func (a *TraeACP) DisplayOrder() int { return 17 }
func (a *TraeACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return traeACPLogoDark
}
return traeACPLogoLight
}
func (a *TraeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(traeACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *TraeACP) BuildCommand(opts CommandOptions) Command {
return Cmd(traeACPBin, "acp", "serve").Build()
}
func (a *TraeACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(traeACPBin, "acp", "serve").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Trae session dir is
// confirmed. Without it, the Docker runtime skips mounting the
// session dir, so NativeSessionResume has no persistence across
// container restarts.
NativeSessionResume: true,
CanRecover: &canRecover,
},
}
}
func (a *TraeACP) RemoteAuth() *RemoteAuth { return nil }
func (a *TraeACP) InstallScript() string {
return "Install Trae IDE CLI from https://trae.ai"
}
func (a *TraeACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *TraeACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(traeACPBin, "acp", "serve"),
}
}
@@ -361,9 +361,20 @@ func (m *Manager) getInstance(ctx context.Context, agentType string) (*instance,
return v.(*instance), ia, nil
}
// probeTimeout caps each ACP probe so an agent that hangs (e.g. one that
// blocks waiting for interactive auth) doesn't keep its UI badge stuck on
// "Probing". 60s is generous enough for cold npm fetches and ACP handshake,
// short enough that the user sees a definitive Error/AuthRequired status
// quickly. Without this bound the only ceiling is the 5-minute HTTP client
// timeout on the agentctl call.
const probeTimeout = 60 * time.Second
// probe runs an ACP probe against the given instance and translates the result
// into an AgentCapabilities record suitable for the cache.
func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent) AgentCapabilities {
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
cfg := ia.InferenceConfig()
req := &agentctlutil.ProbeRequest{
AgentID: inst.agentType,
@@ -373,7 +384,7 @@ func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.Inference
WorkDir: inst.workDir,
},
}
resp, err := inst.client.Probe(ctx, req)
resp, err := inst.client.Probe(probeCtx, req)
now := time.Now()
if err != nil {
return AgentCapabilities{
@@ -51,6 +51,16 @@ func (r *Registry) LoadDefaults() {
agents.NewGemini(),
agents.NewOpenCodeACP(),
agents.NewAmpACP(),
agents.NewQwenACP(),
agents.NewIFlowACP(),
agents.NewDroidACP(),
agents.NewKilocodeACP(),
agents.NewPiACP(),
agents.NewCursorACP(),
agents.NewKimiACP(),
agents.NewKiroACP(),
agents.NewQoderACP(),
agents.NewTraeACP(),
agents.NewMockAgent(),
}
@@ -199,6 +199,29 @@ func (c *Client) SetModel(ctx context.Context, modelID string) error {
return nil
}
// SetConfigOption sets a session config option via the agent WebSocket stream.
func (c *Client) SetConfigOption(ctx context.Context, configID, value string) error {
payload := struct {
ConfigID string `json:"config_id"`
Value string `json:"value"`
}{ConfigID: configID, Value: value}
resp, err := c.sendStreamRequest(ctx, "agent.session.set_config_option", payload)
if err != nil {
return fmt.Errorf("set config option request failed: %w", err)
}
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return fmt.Errorf("set config option failed: unable to parse error")
}
return fmt.Errorf("set config option failed: %s", errPayload.Message)
}
return nil
}
// Authenticate triggers authentication for a given auth method via the agent WebSocket stream.
func (c *Client) Authenticate(ctx context.Context, methodID string) error {
payload := struct {
@@ -106,6 +106,14 @@ type AuthenticatableAdapter interface {
Authenticate(ctx context.Context, methodID string) error
}
// ConfigOptionSettableAdapter is an optional interface implemented by adapters
// that support setting an arbitrary session config option (ACP
// session/set_config_option). Useful for agent-specific runtime knobs that
// aren't covered by mode/model.
type ConfigOptionSettableAdapter interface {
SetConfigOption(ctx context.Context, key, value string) error
}
// SessionResettableAdapter is an optional interface implemented by adapters that
// can reset context by creating a new session on the same connection, without
// restarting the agent subprocess. Only ACP adapters support this.
@@ -6,6 +6,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -133,6 +134,12 @@ type Adapter struct {
// Used by SetModel to validate the requested model exists.
availableModels []acp.UnstableModelInfo
// Available auth methods captured from the ACP initialize response.
// Re-emitted via EventTypeAuthRequired when session/new fails with the
// AuthenticationRequired error code so the frontend can surface a picker
// without re-running initialize.
availableAuthMethods []streams.AuthMethodInfo
// Available modes from the most recent session creation/load.
// Used by SetMode to include cached modes in the event so the
// frontend mode selector can render available options.
@@ -255,13 +262,19 @@ func (a *Adapter) Initialize(ctx context.Context) error {
zap.String("agent_version", a.agentInfo.Version),
zap.Bool("supports_load_session", a.capabilities.LoadSession))
// Cache auth methods so we can re-emit them on auth_required without re-running initialize.
authMethods := convertAuthMethods(resp.AuthMethods)
a.mu.Lock()
a.availableAuthMethods = authMethods
a.mu.Unlock()
// Emit agent capabilities event with prompt capabilities and auth methods
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAgentCapabilities,
SupportsImage: a.capabilities.PromptCapabilities.Image,
SupportsAudio: a.capabilities.PromptCapabilities.Audio,
SupportsEmbeddedContext: a.capabilities.PromptCapabilities.EmbeddedContext,
AuthMethods: convertAuthMethods(resp.AuthMethods),
AuthMethods: authMethods,
})
return nil
@@ -305,6 +318,9 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
})
if err != nil {
span.RecordError(err)
if a.maybeEmitAuthRequired(err) {
return "", fmt.Errorf("authentication required: %w", err)
}
return "", fmt.Errorf("failed to create session: %w", err)
}
@@ -1406,6 +1422,68 @@ func (a *Adapter) SetModel(ctx context.Context, modelID string) error {
return nil
}
// maybeEmitAuthRequired inspects an ACP error and, if it represents an
// AuthenticationRequired (-32000) failure, emits an EventTypeAuthRequired
// carrying the cached auth methods so the frontend can drive the
// authenticate → session/new retry. Returns true when the event was emitted.
//
// The emitted event has no SessionID by design: the failure occurred while
// session/new was attempting to create a session, so no session ID exists
// yet. Consumers that correlate events by session must treat
// EventTypeAuthRequired as a connection-scoped (not session-scoped) signal.
//
// Returns false when no auth methods are cached. Without methods to choose
// from, the frontend can't drive the picker — letting the error fall through
// to the generic "failed to create session" path is more actionable than a
// pseudo-auth-required signal with no options.
func (a *Adapter) maybeEmitAuthRequired(err error) bool {
var reqErr *acp.RequestError
if !errors.As(err, &reqErr) || reqErr.Code != -32000 {
return false
}
a.mu.RLock()
methods := a.availableAuthMethods
a.mu.RUnlock()
if len(methods) == 0 {
return false
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAuthRequired,
AuthMethods: methods,
Error: reqErr.Message,
})
return true
}
// SetConfigOption sets a session configuration option via ACP session/set_config_option.
// configID is the option's ID; value is the option-value ID to apply.
func (a *Adapter) SetConfigOption(ctx context.Context, configID, value string) error {
a.mu.RLock()
conn := a.acpConn
sessionID := a.sessionID
a.mu.RUnlock()
if conn == nil {
return fmt.Errorf("adapter not initialized")
}
if sessionID == "" {
return fmt.Errorf("no active session: call NewSession before SetConfigOption")
}
_, err := conn.SetSessionConfigOption(ctx, acp.SetSessionConfigOptionRequest{
SessionId: acp.SessionId(sessionID),
ConfigId: acp.SessionConfigId(configID),
Value: acp.SessionConfigValueId(value),
})
if err != nil {
return fmt.Errorf("set session config option failed: %w", err)
}
return nil
}
// Authenticate triggers ACP session/authenticate for a given auth method.
func (a *Adapter) Authenticate(ctx context.Context, methodID string) error {
a.mu.RLock()
@@ -0,0 +1,94 @@
package acp
import (
"errors"
"testing"
acp "github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
// TestMaybeEmitAuthRequired_AuthenticationRequiredErrorEmitsEvent verifies
// that a session/new failure with code -32000 (AuthenticationRequired) emits
// an EventTypeAuthRequired event carrying the cached auth methods so the
// frontend can drive the authenticate → session/new retry without re-running
// initialize.
func TestMaybeEmitAuthRequired_AuthenticationRequiredErrorEmitsEvent(t *testing.T) {
a := newTestAdapter()
cached := []streams.AuthMethodInfo{
{ID: "oauth", Name: "OAuth"},
{ID: "api-key", Name: "API key"},
}
a.mu.Lock()
a.availableAuthMethods = cached
a.mu.Unlock()
authErr := acp.NewAuthRequired(nil)
emitted := a.maybeEmitAuthRequired(authErr)
if !emitted {
t.Fatalf("maybeEmitAuthRequired returned false for AuthenticationRequired error")
}
events := drainEvents(a)
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
ev := events[0]
if ev.Type != streams.EventTypeAuthRequired {
t.Errorf("event Type = %q, want %q", ev.Type, streams.EventTypeAuthRequired)
}
if len(ev.AuthMethods) != len(cached) {
t.Fatalf("event AuthMethods len = %d, want %d", len(ev.AuthMethods), len(cached))
}
for i, m := range cached {
if ev.AuthMethods[i].ID != m.ID {
t.Errorf("event AuthMethods[%d].ID = %q, want %q", i, ev.AuthMethods[i].ID, m.ID)
}
}
}
// TestMaybeEmitAuthRequired_NonAuthErrorIsIgnored guards against the helper
// firing on unrelated session/new failures (network errors, MCP misconfig,
// etc.). A real auth_required event must be unambiguous.
func TestMaybeEmitAuthRequired_NonAuthErrorIsIgnored(t *testing.T) {
a := newTestAdapter()
otherErr := &acp.RequestError{Code: -32603, Message: "internal error"}
if a.maybeEmitAuthRequired(otherErr) {
t.Fatalf("maybeEmitAuthRequired returned true for non-auth error code %d", otherErr.Code)
}
if events := drainEvents(a); len(events) != 0 {
t.Errorf("expected 0 events, got %d", len(events))
}
}
// TestMaybeEmitAuthRequired_PlainErrorIsIgnored covers the case where the
// failure isn't even a JSON-RPC RequestError (e.g. transport-level error
// surfaced as a wrapped Go error).
func TestMaybeEmitAuthRequired_PlainErrorIsIgnored(t *testing.T) {
a := newTestAdapter()
if a.maybeEmitAuthRequired(errors.New("connection reset")) {
t.Fatalf("maybeEmitAuthRequired returned true for plain Go error")
}
}
// TestMaybeEmitAuthRequired_EmptyMethodsFallsThrough pins the contract that
// an AuthenticationRequired error with no cached methods does NOT emit an
// EventTypeAuthRequired event. Without methods to pick, the frontend can't
// drive the picker — letting the error fall through to the generic "failed
// to create session" path is more actionable than a pseudo-auth-required
// signal with no options.
func TestMaybeEmitAuthRequired_EmptyMethodsFallsThrough(t *testing.T) {
a := newTestAdapter()
// Leave a.availableAuthMethods empty (Initialize never populated it,
// or the agent didn't advertise any methods).
authErr := acp.NewAuthRequired(nil)
if a.maybeEmitAuthRequired(authErr) {
t.Fatalf("maybeEmitAuthRequired returned true with no cached methods; should fall through")
}
if events := drainEvents(a); len(events) != 0 {
t.Errorf("expected 0 events, got %d", len(events))
}
}
@@ -0,0 +1,23 @@
package acp
import (
"context"
"strings"
"testing"
)
// TestSetConfigOption_WithoutConnectionReturnsError pins the precondition
// that SetConfigOption must surface an error rather than panic when invoked
// before Initialize() has wired up the ACP connection. The same precondition
// is enforced by SetMode/SetModel; this test keeps the new method aligned.
func TestSetConfigOption_WithoutConnectionReturnsError(t *testing.T) {
a := newTestAdapter()
err := a.SetConfigOption(context.Background(), "model", "claude-3-7-sonnet")
if err == nil {
t.Fatalf("expected error when adapter not initialized")
}
if !strings.Contains(err.Error(), "not initialized") {
t.Errorf("error = %q, want one containing %q", err.Error(), "not initialized")
}
}
@@ -262,6 +262,8 @@ func (s *Server) handleAgentStreamRequest(ctx context.Context, msg *ws.Message)
return s.handleWSSetMode(ctx, msg)
case "agent.session.set_model":
return s.handleWSSetModel(ctx, msg)
case "agent.session.set_config_option":
return s.handleWSSetConfigOption(ctx, msg)
case "agent.session.authenticate":
return s.handleWSAuthenticate(ctx, msg)
case "agent.session.reset":
@@ -577,6 +579,20 @@ func (s *Server) handleWSSetModel(ctx context.Context, msg *ws.Message) *ws.Mess
})
}
func (s *Server) handleWSSetConfigOption(ctx context.Context, msg *ws.Message) *ws.Message {
var req struct {
ConfigID string `json:"config_id"`
Value string `json:"value"`
}
return s.adapterAction(ctx, msg, &req, func(a adapter.AgentAdapter) error {
cs, ok := a.(adapter.ConfigOptionSettableAdapter)
if !ok {
return fmt.Errorf("agent does not support set_config_option")
}
return cs.SetConfigOption(ctx, req.ConfigID, req.Value)
})
}
func (s *Server) handleWSAuthenticate(ctx context.Context, msg *ws.Message) *ws.Message {
var req struct {
MethodID string `json:"method_id"`
@@ -0,0 +1,57 @@
package api
import (
"context"
"strings"
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
)
// TestHandleWSSetConfigOption_NoAdapter verifies the WS handler surfaces a
// clean "agent not running" error when no adapter is connected — same as the
// other adapter-action handlers (set_mode, set_model, authenticate).
func TestHandleWSSetConfigOption_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.session.set_config_option", map[string]string{
"config_id": "model",
"value": "claude-3-7-sonnet",
})
resp := s.handleWSSetConfigOption(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
// TestHandleWSSetConfigOption_BadPayload verifies the handler rejects
// malformed payloads with BAD_REQUEST (the adapterAction helper parses the
// payload before checking the adapter, so a malformed payload errors first).
func TestHandleWSSetConfigOption_BadPayload(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
// Use a non-JSON-decodable payload to trip the parse path.
msg, _ := ws.NewRequest("req-1", "agent.session.set_config_option", "not-an-object")
resp := s.handleWSSetConfigOption(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if errPayload.Code != ws.ErrorCodeBadRequest {
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
}
}
@@ -54,6 +54,12 @@ const (
// EventTypeSessionModels indicates available models from ACP session/new.
EventTypeSessionModels = "session_models"
// EventTypeAuthRequired indicates the agent rejected session/new with an
// authentication-required error. The event carries the available auth
// methods (from ACP initialize); the client picks one and replays the
// authenticate → session/new round-trip.
EventTypeAuthRequired = "auth_required"
)
// Session status constants for EventTypeSessionStatus events.
+50 -11
View File
@@ -152,18 +152,57 @@ function useOnboardingResources(open: boolean) {
useEffect(() => {
if (!open) return;
Promise.all([listAvailableAgents({ cache: "no-store" }), listAgentsAction()])
.then(([availRes, savedRes]) => {
setAvailableAgents(availRes.agents ?? []);
setTools(availRes.tools ?? []);
setAgentSettings(buildAgentSettings(availRes.agents ?? [], savedRes.agents ?? []));
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// Poll while any agent is still in the "probing" state — the host-utility
// probes async at boot and the dialog can open before they all resolve.
// Without re-polling, agents that flip status mid-session stay stuck on
// the initial badge in the UI. Re-poll on transient fetch errors too:
// a single 500 shouldn't strand the dialog on stale probing status.
let lastSawProbing = true;
const pollOnce = (firstRun: boolean) => {
Promise.all([
listAvailableAgents({ cache: "no-store" }),
firstRun ? listAgentsAction() : Promise.resolve(null),
])
.then(([availRes, savedRes]) => {
if (cancelled) return;
const agents = availRes.agents ?? [];
setAvailableAgents(agents);
setTools(availRes.tools ?? []);
if (savedRes) {
setAgentSettings(buildAgentSettings(agents, savedRes.agents ?? []));
}
lastSawProbing = agents.some((a) => a.model_config.status === "probing");
})
.catch(() => {
// Keep polling on transient errors — backend may be momentarily
// unreachable while still resolving probes.
})
.finally(() => {
if (cancelled) return;
if (firstRun) setLoadingAgents(false);
if (lastSawProbing) {
timeoutId = setTimeout(() => pollOnce(false), 2000);
}
});
};
pollOnce(true);
listWorkflowTemplates()
.then((res) => {
if (!cancelled) setTemplates(res.templates ?? []);
})
.catch(() => {})
.finally(() => setLoadingAgents(false));
listWorkflowTemplates()
.then((res) => setTemplates(res.templates ?? []))
.catch(() => {})
.finally(() => setLoadingTemplates(false));
.finally(() => {
if (!cancelled) setLoadingTemplates(false);
});
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [open]);
return {
@@ -265,7 +304,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-[540px]" showCloseButton={false}>
<DialogContent className="sm:max-w-3xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-center text-2xl">{STEP_TITLES[step]}</DialogTitle>
<DialogDescription className="text-center">{STEP_DESCRIPTIONS[step]}</DialogDescription>
+23 -6
View File
@@ -12,6 +12,7 @@ import {
IconLock,
} from "@tabler/icons-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { AgentLogo } from "@/components/agent-logo";
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
@@ -56,26 +57,42 @@ function CopyButton({ text }: { text: string }) {
);
}
function StatusPill({ status }: { status: string }) {
function StatusPill({ status, error }: { status: string; error?: string }) {
switch (status) {
case "auth_required":
return (
case "auth_required": {
const pill = (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<IconLock className="h-3.5 w-3.5" />
No auth
</span>
);
if (!error) return pill;
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent className="max-w-sm">{error}</TooltipContent>
</Tooltip>
);
}
case "not_installed":
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">Not installed</span>
);
case "failed":
return (
case "failed": {
const pill = (
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<IconAlertTriangle className="h-3.5 w-3.5" />
Error
</span>
);
if (!error) return pill;
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent className="max-w-sm">{error}</TooltipContent>
</Tooltip>
);
}
case "probing":
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -130,7 +147,7 @@ function InstalledAgentRow({
{modelName}
</span>
)}
<StatusPill status={status} />
<StatusPill status={status} error={agent.model_config.error} />
<IconChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</button>
</CollapsibleTrigger>