diff --git a/README.md b/README.md index 409c619fa1..9ee11f277a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/backend/internal/agent/agents/cursor_acp.go b/apps/backend/internal/agent/agents/cursor_acp.go new file mode 100644 index 0000000000..68dcdf1c42 --- /dev/null +++ b/apps/backend/internal/agent/agents/cursor_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/detect.go b/apps/backend/internal/agent/agents/detect.go index c05fdaaa17..d19ad79f70 100644 --- a/apps/backend/internal/agent/agents/detect.go +++ b/apps/backend/internal/agent/agents/detect.go @@ -40,6 +40,35 @@ func WithCommand(name string) DetectOption { } } +// WithNpxRunnable reports the agent as runnable via `npx -y ` whenever +// npx is on PATH. MatchedPath is tagged "npx " so the settings page +// renders "Detected at npx ", 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 `, 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) { diff --git a/apps/backend/internal/agent/agents/detect_test.go b/apps/backend/internal/agent/agents/detect_test.go new file mode 100644 index 0000000000..fa850988fa --- /dev/null +++ b/apps/backend/internal/agent/agents/detect_test.go @@ -0,0 +1,86 @@ +package agents + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +// TestWithNpxRunnable_MatchesWhenNpxOnPath confirms the npx-fallback returns +// available with the "npx " tag whenever npx is on PATH. The settings +// page renders this as "Detected at npx ", 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/" 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 +} diff --git a/apps/backend/internal/agent/agents/droid_acp.go b/apps/backend/internal/agent/agents/droid_acp.go new file mode 100644 index 0000000000..985d27f32b --- /dev/null +++ b/apps/backend/internal/agent/agents/droid_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/iflow_acp.go b/apps/backend/internal/agent/agents/iflow_acp.go new file mode 100644 index 0000000000..2e162ee1fe --- /dev/null +++ b/apps/backend/internal/agent/agents/iflow_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/kilocode_acp.go b/apps/backend/internal/agent/agents/kilocode_acp.go new file mode 100644 index 0000000000..3e259f95ee --- /dev/null +++ b/apps/backend/internal/agent/agents/kilocode_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/kimi_acp.go b/apps/backend/internal/agent/agents/kimi_acp.go new file mode 100644 index 0000000000..05339bdd64 --- /dev/null +++ b/apps/backend/internal/agent/agents/kimi_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/kiro_acp.go b/apps/backend/internal/agent/agents/kiro_acp.go new file mode 100644 index 0000000000..ab4f23fc31 --- /dev/null +++ b/apps/backend/internal/agent/agents/kiro_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/logos/cursor_acp_dark.svg b/apps/backend/internal/agent/agents/logos/cursor_acp_dark.svg new file mode 100644 index 0000000000..60092bd913 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/cursor_acp_dark.svg @@ -0,0 +1 @@ +Cursor diff --git a/apps/backend/internal/agent/agents/logos/cursor_acp_light.svg b/apps/backend/internal/agent/agents/logos/cursor_acp_light.svg new file mode 100644 index 0000000000..0d7392304c --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/cursor_acp_light.svg @@ -0,0 +1 @@ +Cursor diff --git a/apps/backend/internal/agent/agents/logos/droid_acp_dark.svg b/apps/backend/internal/agent/agents/logos/droid_acp_dark.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/droid_acp_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/droid_acp_light.svg b/apps/backend/internal/agent/agents/logos/droid_acp_light.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/droid_acp_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/iflow_acp_dark.svg b/apps/backend/internal/agent/agents/logos/iflow_acp_dark.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/iflow_acp_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/iflow_acp_light.svg b/apps/backend/internal/agent/agents/logos/iflow_acp_light.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/iflow_acp_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kilocode_acp_dark.svg b/apps/backend/internal/agent/agents/logos/kilocode_acp_dark.svg new file mode 100644 index 0000000000..ff0526264a --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kilocode_acp_dark.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kilocode_acp_light.svg b/apps/backend/internal/agent/agents/logos/kilocode_acp_light.svg new file mode 100644 index 0000000000..ff0526264a --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kilocode_acp_light.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kimi_acp_dark.svg b/apps/backend/internal/agent/agents/logos/kimi_acp_dark.svg new file mode 100644 index 0000000000..5d0e2061e1 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kimi_acp_dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kimi_acp_light.svg b/apps/backend/internal/agent/agents/logos/kimi_acp_light.svg new file mode 100644 index 0000000000..5d0e2061e1 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kimi_acp_light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kiro_acp_dark.svg b/apps/backend/internal/agent/agents/logos/kiro_acp_dark.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kiro_acp_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/kiro_acp_light.svg b/apps/backend/internal/agent/agents/logos/kiro_acp_light.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/kiro_acp_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/pi_acp_dark.svg b/apps/backend/internal/agent/agents/logos/pi_acp_dark.svg new file mode 100644 index 0000000000..ed14b63805 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/pi_acp_dark.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/backend/internal/agent/agents/logos/pi_acp_light.svg b/apps/backend/internal/agent/agents/logos/pi_acp_light.svg new file mode 100644 index 0000000000..586c4ecf7d --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/pi_acp_light.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/backend/internal/agent/agents/logos/qoder_acp_dark.svg b/apps/backend/internal/agent/agents/logos/qoder_acp_dark.svg new file mode 100644 index 0000000000..c25f9b5d42 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/qoder_acp_dark.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/qoder_acp_light.svg b/apps/backend/internal/agent/agents/logos/qoder_acp_light.svg new file mode 100644 index 0000000000..c25f9b5d42 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/qoder_acp_light.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/qwen_acp_dark.svg b/apps/backend/internal/agent/agents/logos/qwen_acp_dark.svg new file mode 100644 index 0000000000..255e278e3f --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/qwen_acp_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/qwen_acp_light.svg b/apps/backend/internal/agent/agents/logos/qwen_acp_light.svg new file mode 100644 index 0000000000..255e278e3f --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/qwen_acp_light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/trae_acp_dark.svg b/apps/backend/internal/agent/agents/logos/trae_acp_dark.svg new file mode 100644 index 0000000000..08b3ba0237 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/trae_acp_dark.svg @@ -0,0 +1 @@ +TRAE \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/trae_acp_light.svg b/apps/backend/internal/agent/agents/logos/trae_acp_light.svg new file mode 100644 index 0000000000..08b3ba0237 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/trae_acp_light.svg @@ -0,0 +1 @@ +TRAE \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/new_acp_agents_test.go b/apps/backend/internal/agent/agents/new_acp_agents_test.go new file mode 100644 index 0000000000..0ae3279f6c --- /dev/null +++ b/apps/backend/internal/agent/agents/new_acp_agents_test.go @@ -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 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 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) + } +} diff --git a/apps/backend/internal/agent/agents/pi_acp.go b/apps/backend/internal/agent/agents/pi_acp.go new file mode 100644 index 0000000000..6f57957818 --- /dev/null +++ b/apps/backend/internal/agent/agents/pi_acp.go @@ -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), + } +} diff --git a/apps/backend/internal/agent/agents/qoder_acp.go b/apps/backend/internal/agent/agents/qoder_acp.go new file mode 100644 index 0000000000..7248318dc7 --- /dev/null +++ b/apps/backend/internal/agent/agents/qoder_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/qwen_acp.go b/apps/backend/internal/agent/agents/qwen_acp.go new file mode 100644 index 0000000000..2fc19337d1 --- /dev/null +++ b/apps/backend/internal/agent/agents/qwen_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/agents/trae_acp.go b/apps/backend/internal/agent/agents/trae_acp.go new file mode 100644 index 0000000000..66120ddf78 --- /dev/null +++ b/apps/backend/internal/agent/agents/trae_acp.go @@ -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"), + } +} diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index a7909ea9e4..a61baa7f23 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -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{ diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 2c88fd611c..4947c90d90 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -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(), } diff --git a/apps/backend/internal/agentctl/client/agent.go b/apps/backend/internal/agentctl/client/agent.go index 73609870e5..01235db88f 100644 --- a/apps/backend/internal/agentctl/client/agent.go +++ b/apps/backend/internal/agentctl/client/agent.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index ed9ae2bdc4..e2cfa8a86e 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -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. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 0cee791d01..b263023d8d 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -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() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/auth_required_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/auth_required_test.go new file mode 100644 index 0000000000..63583dd41d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/auth_required_test.go @@ -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)) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/set_config_option_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/set_config_option_test.go new file mode 100644 index 0000000000..59d89888be --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/set_config_option_test.go @@ -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") + } +} diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index bc40da3196..5cf0a06d62 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -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"` diff --git a/apps/backend/internal/agentctl/server/api/set_config_option_test.go b/apps/backend/internal/agentctl/server/api/set_config_option_test.go new file mode 100644 index 0000000000..0fe6546cca --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/set_config_option_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 4f295a8153..b743fa6c94 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -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. diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index 58e9ad3bec..513a8c6974 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -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 | 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 ( {}}> - + {STEP_TITLES[step]} {STEP_DESCRIPTIONS[step]} diff --git a/apps/web/components/onboarding/step-agents.tsx b/apps/web/components/onboarding/step-agents.tsx index 391cc43045..a839c54cdf 100644 --- a/apps/web/components/onboarding/step-agents.tsx +++ b/apps/web/components/onboarding/step-agents.tsx @@ -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 = ( No auth ); + if (!error) return pill; + return ( + + {pill} + {error} + + ); + } case "not_installed": return ( Not installed ); - case "failed": - return ( + case "failed": { + const pill = ( Error ); + if (!error) return pill; + return ( + + {pill} + {error} + + ); + } case "probing": return ( @@ -130,7 +147,7 @@ function InstalledAgentRow({ {modelName} )} - +