feat(agent): add Oh My Pi ACP agent (#971)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" width="1em" height="1em"><title>Oh My Pi</title><rect x="10" y="8" width="100" height="12" rx="2" fill="#fafafa"/><rect x="25" y="20" width="12" height="62" rx="2" fill="#fafafa"/><rect x="75" y="20" width="12" height="45" rx="2" fill="#fafafa"/><rect x="71" y="55" width="20" height="16" rx="3" fill="#f97316"/><rect x="76" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/><rect x="82" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/><circle cx="18" cy="14" r="2" fill="#f97316" opacity="0.8"/><circle cx="102" cy="14" r="2" fill="#f97316" opacity="0.8"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" width="1em" height="1em"><title>Oh My Pi</title><rect x="10" y="8" width="100" height="12" rx="2" fill="#0d0d0d"/><rect x="25" y="20" width="12" height="62" rx="2" fill="#0d0d0d"/><rect x="75" y="20" width="12" height="45" rx="2" fill="#0d0d0d"/><rect x="71" y="55" width="20" height="16" rx="3" fill="#f97316"/><rect x="76" y="59" width="3" height="8" rx="1" fill="#fafafa"/><rect x="82" y="59" width="3" height="8" rx="1" fill="#fafafa"/><circle cx="18" cy="14" r="2" fill="#f97316" opacity="0.8"/><circle cx="102" cy="14" r="2" fill="#f97316" opacity="0.8"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -104,6 +104,13 @@ var newACPAgentSpecs = []struct {
|
||||
passthroughArgv: []string{"traecli"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
{func() Agent { return NewOmpACP() }, acpAgentSpec{
|
||||
id: "omp-acp", displayName: "omp", detectBinaries: []string{"omp"},
|
||||
expectedArgv: []string{"omp", "acp"},
|
||||
inferenceArgv: []string{"omp", "acp"},
|
||||
passthroughArgv: []string{"omp"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
}
|
||||
|
||||
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//nolint:dupl,goconst // Native-binary ACP agents (Cursor, Kimi, Kiro, Omp, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand. Shared literals (`CLI Passthrough`, `Show terminal directly instead of chat interface`, `{workspace}`) live in every peer file by convention.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/omp_acp_light.svg
|
||||
var ompACPLogoLight []byte
|
||||
|
||||
//go:embed logos/omp_acp_dark.svg
|
||||
var ompACPLogoDark []byte
|
||||
|
||||
const ompACPBin = "omp"
|
||||
|
||||
var (
|
||||
_ Agent = (*OmpACP)(nil)
|
||||
_ PassthroughAgent = (*OmpACP)(nil)
|
||||
_ InferenceAgent = (*OmpACP)(nil)
|
||||
)
|
||||
|
||||
// OmpACP implements Agent for the Oh My Pi (omp) coding agent via its native
|
||||
// `omp acp` subcommand. Distributed as a single binary (bun-installed); BYO
|
||||
// API key — omp reads any of the dozen provider env vars (ANTHROPIC_API_KEY,
|
||||
// OPENAI_API_KEY, GEMINI_API_KEY, ...) directly.
|
||||
type OmpACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewOmpACP() *OmpACP {
|
||||
return &OmpACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(ompACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
ResumeFlag: NewParam("-c"),
|
||||
SessionResumeFlag: NewParam("--resume"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OmpACP) ID() string { return "omp-acp" }
|
||||
func (a *OmpACP) Name() string { return "Oh My Pi ACP Agent" }
|
||||
func (a *OmpACP) DisplayName() string { return ompACPBin }
|
||||
func (a *OmpACP) Description() string {
|
||||
return "Oh My Pi (omp) coding agent using the ACP protocol via the `omp acp` subcommand."
|
||||
}
|
||||
func (a *OmpACP) Enabled() bool { return true }
|
||||
func (a *OmpACP) DisplayOrder() int { return 18 }
|
||||
|
||||
func (a *OmpACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return ompACPLogoDark
|
||||
}
|
||||
return ompACPLogoLight
|
||||
}
|
||||
|
||||
func (a *OmpACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(ompACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *OmpACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(ompACPBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *OmpACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(ompACPBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
ProjectSkillDir: ".omp/skills",
|
||||
UserSkillDir: ".omp/agent/skills",
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.omp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OmpACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *OmpACP) InstallScript() string {
|
||||
return "bun install -g @oh-my-pi/pi-coding-agent"
|
||||
}
|
||||
|
||||
func (a *OmpACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *OmpACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(ompACPBin, "acp"),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OmpACP) BillingType() usage.BillingType { return defaultBillingType() }
|
||||
@@ -61,6 +61,7 @@ func (r *Registry) LoadDefaults() {
|
||||
agents.NewKiroACP(),
|
||||
agents.NewQoderACP(),
|
||||
agents.NewTraeACP(),
|
||||
agents.NewOmpACP(),
|
||||
agents.NewMockAgent(),
|
||||
}
|
||||
|
||||
|
||||
@@ -313,8 +313,10 @@ func (s *Server) handleWSInitialize(ctx context.Context, msg *ws.Message) *ws.Me
|
||||
}
|
||||
|
||||
// injectKandevMcpServers prepends the local kandev MCP server to the list of MCP servers.
|
||||
// Both SSE and HTTP variants are injected - the agent's capability filtering will select
|
||||
// the appropriate one based on what the agent supports (SSE for Claude Code, HTTP for Codex ACP).
|
||||
// Both HTTP and SSE variants are injected - the agent's capability filtering will select
|
||||
// the appropriate one based on what the agent supports. HTTP is listed first so that
|
||||
// when an agent advertises both transports the "first surviving entry wins" dedup keeps
|
||||
// the HTTP entry (modern streamable MCP); SSE remains as a fallback for SSE-only agents.
|
||||
// Any existing kandev server in the list is filtered out to avoid duplicates.
|
||||
func (s *Server) injectKandevMcpServers(mcpServers []types.McpServer) []types.McpServer {
|
||||
kandevMcpSse := types.McpServer{
|
||||
@@ -328,15 +330,15 @@ func (s *Server) injectKandevMcpServers(mcpServers []types.McpServer) []types.Mc
|
||||
URL: fmt.Sprintf("http://localhost:%d%s", s.cfg.Port, mcpPathHTTP),
|
||||
}
|
||||
filtered := make([]types.McpServer, 0, len(mcpServers)+2)
|
||||
filtered = append(filtered, kandevMcpSse, kandevMcpHttp)
|
||||
filtered = append(filtered, kandevMcpHttp, kandevMcpSse)
|
||||
for _, srv := range mcpServers {
|
||||
if srv.Name != kandevMcpServerName {
|
||||
filtered = append(filtered, srv)
|
||||
}
|
||||
}
|
||||
s.logger.Debug("injected local kandev MCP servers (sse+http)",
|
||||
zap.String("sse_url", kandevMcpSse.URL),
|
||||
s.logger.Debug("injected local kandev MCP servers (http+sse)",
|
||||
zap.String("http_url", kandevMcpHttp.URL),
|
||||
zap.String("sse_url", kandevMcpSse.URL),
|
||||
zap.Int("total_servers", len(filtered)))
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
@@ -490,3 +491,44 @@ func TestAgentStreamWS_MalformedMessage(t *testing.T) {
|
||||
t.Errorf("expected response after malformed message, got %q", resp.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// --- injectKandevMcpServers ordering ---
|
||||
|
||||
// TestInjectKandevMcpServers_HttpFirst ensures the kandev MCP HTTP entry is injected
|
||||
// before the SSE entry so that the capability-filter dedup (first surviving entry per
|
||||
// name wins) prefers the HTTP transport whenever an agent advertises both
|
||||
// mcpCapabilities.http and mcpCapabilities.sse. SSE-only agents still pick up the SSE
|
||||
// fallback.
|
||||
func TestInjectKandevMcpServers_HttpFirst(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
got := srv.injectKandevMcpServers(nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 kandev entries, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Name != kandevMcpServerName || got[0].Type != mcpTransportHTTP {
|
||||
t.Errorf("expected first entry name=%q type=%q, got name=%q type=%q",
|
||||
kandevMcpServerName, mcpTransportHTTP, got[0].Name, got[0].Type)
|
||||
}
|
||||
if got[1].Name != kandevMcpServerName || got[1].Type != mcpTransportSSE {
|
||||
t.Errorf("expected second entry name=%q type=%q, got name=%q type=%q",
|
||||
kandevMcpServerName, mcpTransportSSE, got[1].Name, got[1].Type)
|
||||
}
|
||||
|
||||
// Upstream non-kandev entries must be preserved and appended after the injected pair;
|
||||
// any upstream "kandev" must be filtered out.
|
||||
upstream := []types.McpServer{
|
||||
{Name: "other", Type: "stdio", Command: "x"},
|
||||
{Name: kandevMcpServerName, Type: mcpTransportSSE, URL: "http://stale/sse"},
|
||||
}
|
||||
got = srv.injectKandevMcpServers(upstream)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 entries (http+sse+other), got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Type != mcpTransportHTTP || got[1].Type != mcpTransportSSE {
|
||||
t.Errorf("expected injected order http,sse; got %q,%q", got[0].Type, got[1].Type)
|
||||
}
|
||||
if got[2].Name != "other" || got[2].Command != "x" {
|
||||
t.Errorf("expected upstream 'other' entry last, got %+v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +465,10 @@ const kandevMcpServerName = "kandev"
|
||||
// injectKandevMcpServer prepends the local kandev MCP server to the list of MCP servers.
|
||||
// This replaces any existing kandev server to avoid duplicates.
|
||||
// The kandev MCP server provides tools like ask_user_question to the agent.
|
||||
// Both SSE and HTTP variants are injected - agent capability filtering will select the appropriate one.
|
||||
// Both HTTP and SSE variants are injected - agent capability filtering will select the
|
||||
// appropriate one. HTTP is listed first so that when an agent advertises both transports
|
||||
// the "first surviving entry wins" dedup keeps the HTTP entry (modern streamable MCP);
|
||||
// SSE remains as a fallback for SSE-only agents.
|
||||
func injectKandevMcpServer(servers []McpServerConfig, port int) []McpServerConfig {
|
||||
portStr := strconv.Itoa(port)
|
||||
kandevMcpSse := McpServerConfig{
|
||||
@@ -481,7 +484,7 @@ func injectKandevMcpServer(servers []McpServerConfig, port int) []McpServerConfi
|
||||
|
||||
// Filter out any existing kandev server and prepend the local ones
|
||||
result := make([]McpServerConfig, 0, len(servers)+2)
|
||||
result = append(result, kandevMcpSse, kandevMcpHttp)
|
||||
result = append(result, kandevMcpHttp, kandevMcpSse)
|
||||
for _, srv := range servers {
|
||||
if srv.Name != kandevMcpServerName {
|
||||
result = append(result, srv)
|
||||
|
||||
@@ -75,3 +75,43 @@ func TestGenerateSelfToken(t *testing.T) {
|
||||
t.Fatal("expected unique tokens, got identical")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectKandevMcpServer_HttpFirst is the McpServerConfig counterpart to
|
||||
// api.TestInjectKandevMcpServers_HttpFirst. HTTP must appear before SSE so the
|
||||
// downstream capability filter dedup keeps the HTTP transport for agents that
|
||||
// advertise both transports.
|
||||
func TestInjectKandevMcpServer_HttpFirst(t *testing.T) {
|
||||
got := injectKandevMcpServer(nil, 12345)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 kandev entries, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Name != kandevMcpServerName || got[0].Type != "http" {
|
||||
t.Errorf("expected first entry name=%q type=http, got name=%q type=%q",
|
||||
kandevMcpServerName, got[0].Name, got[0].Type)
|
||||
}
|
||||
if got[0].URL != "http://localhost:12345/mcp" {
|
||||
t.Errorf("expected http URL .../mcp, got %q", got[0].URL)
|
||||
}
|
||||
if got[1].Name != kandevMcpServerName || got[1].Type != "sse" {
|
||||
t.Errorf("expected second entry name=%q type=sse, got name=%q type=%q",
|
||||
kandevMcpServerName, got[1].Name, got[1].Type)
|
||||
}
|
||||
if got[1].URL != "http://localhost:12345/sse" {
|
||||
t.Errorf("expected sse URL .../sse, got %q", got[1].URL)
|
||||
}
|
||||
|
||||
upstream := []McpServerConfig{
|
||||
{Name: "other", Type: "stdio", Command: "x"},
|
||||
{Name: kandevMcpServerName, Type: "sse", URL: "http://stale/sse"},
|
||||
}
|
||||
got = injectKandevMcpServer(upstream, 12345)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 entries (http+sse+other), got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Type != "http" || got[1].Type != "sse" {
|
||||
t.Errorf("expected injected order http,sse; got %q,%q", got[0].Type, got[1].Type)
|
||||
}
|
||||
if got[2].Name != "other" || got[2].Command != "x" {
|
||||
t.Errorf("expected upstream 'other' entry last, got %+v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,10 +475,16 @@ func derefString(p *string) string {
|
||||
// is not derived from untrusted input — even though the value is
|
||||
// semantically the same as the base name taken from InferenceConfig.Command.
|
||||
var allowedProbeCommands = map[string]string{
|
||||
"npx": "npx",
|
||||
"auggie": "auggie",
|
||||
"opencode": "opencode",
|
||||
"mock-agent": "mock-agent",
|
||||
"auggie": "auggie",
|
||||
"cursor-agent": "cursor-agent",
|
||||
"kimi": "kimi",
|
||||
"kiro-cli-chat": "kiro-cli-chat",
|
||||
"mock-agent": "mock-agent",
|
||||
"npx": "npx",
|
||||
"omp": "omp",
|
||||
"opencode": "opencode",
|
||||
"qodercli": "qodercli",
|
||||
"traecli": "traecli",
|
||||
}
|
||||
|
||||
// resolveProbeCommand validates and returns a hard-coded executable name for
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package utility
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// TestProbeAllowlist_CoversEveryEnabledInferenceAgent guards against the
|
||||
// `command "<bin>" is not an allowed ACP probe command` regression: every
|
||||
// agent the registry exposes as an InferenceAgent must have its primary
|
||||
// command (filepath.Base of cfg.Command.Args()[0]) listed in
|
||||
// allowedProbeCommands, otherwise the host-utility probe rejects it the
|
||||
// moment a user installs that CLI on PATH.
|
||||
func TestProbeAllowlist_CoversEveryEnabledInferenceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.NewLogger: %v", err)
|
||||
}
|
||||
|
||||
reg := registry.NewRegistry(log)
|
||||
reg.LoadDefaults()
|
||||
|
||||
for _, ag := range reg.ListEnabled() {
|
||||
ia, ok := ag.(agents.InferenceAgent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cfg := ia.InferenceConfig()
|
||||
if cfg == nil || !cfg.Supported || cfg.Command.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
primary := cfg.Command.Args()[0]
|
||||
if resolveProbeCommand(primary) == "" {
|
||||
t.Errorf("agent %q: probe binary %q missing from allowedProbeCommands", ag.ID(), primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user