feat(agents): add Goose ACP agent support (#3313)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Jelloeater's Agent
2026-09-04 03:58:58 +00:00
committed by GitHub
parent 76825c109e
commit 884418b4c9
9 changed files with 719 additions and 1 deletions
@@ -0,0 +1,220 @@
//nolint:dupl,goconst // Native-binary ACP agents (Kiro, Qoder, Hermes, Devin, ...) follow the same minimal scaffold; differences are the binary name, argv, and auth surface. Shared literals live in every peer file by convention.
package agents
import (
"context"
_ "embed"
"os/exec"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/goose_light.svg
var gooseLogoLight []byte
//go:embed logos/goose_dark.svg
var gooseLogoDark []byte
const gooseBin = "goose"
const gooseACPHelpMarker = "run goose as an acp agent"
var (
_ Agent = (*GooseACP)(nil)
_ PassthroughAgent = (*GooseACP)(nil)
_ InferenceAgent = (*GooseACP)(nil)
_ LoginAgent = (*GooseACP)(nil)
)
// GooseACP implements Agent for the Goose coding agent (Agentic AI
// Foundation / AAIF, formerly Block) using native ACP over stdin/stdout
// (`goose acp`). Goose is installed as a standalone native binary
// (`download_cli.sh`, Homebrew `block-goose-cli`, or `pip install goose-ai`),
// so the launch command is the bare `goose` binary discovered on PATH.
//
// Auth is declarative: Goose reads its provider + credential config from
// ~/.config/goose (not environment variables), so `goose configure` is the
// interactive setup, and the primary remote-auth path is a config-file copy.
type GooseACP struct {
StandardPassthrough
}
func NewGooseACP() *GooseACP {
return &GooseACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(gooseBin),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *GooseACP) ID() string { return "goose-acp" }
func (a *GooseACP) Name() string { return "Goose ACP Agent" }
func (a *GooseACP) DisplayName() string { return "Goose" }
func (a *GooseACP) Description() string {
return "AAIF Goose coding agent using the ACP protocol via goose acp. Local, extensible, open source."
}
func (a *GooseACP) Enabled() bool { return true }
func (a *GooseACP) DisplayOrder() int { return 22 }
func (a *GooseACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return gooseLogoDark
}
return gooseLogoLight
}
func (a *GooseACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// `goose acp` is a blocking server, so check its help surface instead of
// starting the server or opening provider configuration. The subcommand
// check also rejects unrelated binaries that happen to be named `goose`.
result, err := Detect(ctx, detectGoose)
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func detectGoose(ctx context.Context) (bool, string, error) {
path, err := exec.LookPath(gooseBin)
if err != nil {
return false, "", nil
}
checkCtx, cancel := context.WithTimeout(ctx, commandCheckTimeout)
defer cancel()
output, err := exec.CommandContext(checkCtx, path, "acp", "--help").CombinedOutput()
if err != nil {
if ctx.Err() != nil {
return false, "", ctx.Err()
}
return false, "", nil
}
help := strings.ToLower(string(output))
if !strings.Contains(help, gooseACPHelpMarker) {
return false, "", nil
}
return true, path, nil
}
func (a *GooseACP) BuildCommand(_ CommandOptions) Command {
return Cmd(gooseBin, "acp").Build()
}
func (a *GooseACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(gooseBin, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: DefaultResourceLimits,
Protocol: agent.ProtocolACP,
// Goose supports Stdio and HTTP (StreamableHttp) MCP servers but
// rejects SSE ("migrate to streamable_http"); do not force SSE.
// Goose authenticates via its ~/.config/goose config files (not
// provider API-key env vars), so no StripEnv is declared.
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.local/share/goose",
SessionDirTarget: "/root/.local/share/goose",
},
}
}
func (a *GooseACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: "files",
Label: "Copy Goose config files",
SourceFiles: map[string][]string{
"darwin": {".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"},
"linux": {".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"},
},
// Provider + model config only. Sessions, checkpoints, and
// history under ~/.local/share/goose are deliberately
// excluded; credentials stored in the OS keyring are not
// represented by config files and must be set up remotely.
TargetRelDir: ".config/goose",
},
},
}
}
// goose configure is the interactive provider/model setup wizard (OAuth
// flows, API keys). Credentials land in ~/.config/goose.
func (a *GooseACP) LoginCommand() *LoginCommand {
return &LoginCommand{
Cmd: []string{gooseBin, "configure"},
Description: "Configure Goose model provider credentials.",
}
}
func (a *GooseACP) InstallScript() string {
// Keep the installer in a temporary file so curl failures cannot be hidden
// by a successful bash exit status. Install into the first writable absolute
// directory already on PATH so the parent Kandev process can discover Goose
// immediately after the install job finishes.
return `set -eu
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh -o "$tmp"
install_dir=""
old_ifs="$IFS"
IFS=:
for dir in ${PATH:-}; do
[ -n "$dir" ] || continue
case "$dir" in
/*) ;;
*) continue ;;
esac
dir=${dir%/}
[ -n "$dir" ] || dir=/
if [ -d "$dir" ] && [ -w "$dir" ]; then
install_dir="$dir"
break
fi
done
IFS="$old_ifs"
if [ -z "$install_dir" ]; then
echo "Error: no writable absolute directory is available on PATH for Goose" >&2
exit 1
fi
GOOSE_BIN_DIR="$install_dir" CONFIGURE=false bash "$tmp"
command -v goose >/dev/null 2>&1
resolved="$(command -v goose)"
if [ "$resolved" != "$install_dir/goose" ]; then
echo "Error: Goose installed at $install_dir/goose but another goose is earlier on PATH" >&2
exit 1
fi
"$install_dir/goose" acp --help >/dev/null 2>&1`
}
func (a *GooseACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *GooseACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(gooseBin, "acp"),
}
}
func (a *GooseACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -0,0 +1,353 @@
package agents
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
func TestGooseACP_IDAndDisplay(t *testing.T) {
a := NewGooseACP()
if got := a.ID(); got != "goose-acp" {
t.Errorf("ID() = %q, want goose-acp", got)
}
if got := a.Name(); got != "Goose ACP Agent" {
t.Errorf("Name() = %q, want Goose ACP Agent", got)
}
if got := a.DisplayName(); got != "Goose" {
t.Errorf("DisplayName() = %q, want Goose", got)
}
if !a.Enabled() {
t.Error("Enabled() = false, want true")
}
if got := a.DisplayOrder(); got != 22 {
t.Errorf("DisplayOrder() = %d, want 22", got)
}
if got := a.Description(); !strings.Contains(got, "goose acp") {
t.Errorf("Description should mention the ACP launch command, got %q", got)
}
}
func TestGooseACP_AllCommandSurfaces(t *testing.T) {
a := NewGooseACP()
want := []string{"goose", "acp"}
assertArgvEqual(t, "BuildCommand", a.BuildCommand(CommandOptions{}).Args(), want)
rt := a.Runtime()
if rt == nil {
t.Fatal("Runtime() returned nil")
}
if rt.Protocol != agent.ProtocolACP {
t.Errorf("Runtime.Protocol = %q, want ACP", rt.Protocol)
}
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), want)
ic := a.InferenceConfig()
if ic == nil || !ic.Supported {
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
}
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), want)
pa, ok := any(a).(PassthroughAgent)
if !ok {
t.Fatal("GooseACP must implement PassthroughAgent")
}
assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), []string{"goose"})
}
func TestGooseACP_DoesNotForceSSE(t *testing.T) {
rt := NewGooseACP().Runtime()
// Goose supports Stdio + HTTP (StreamableHttp) MCP but rejects SSE; forcing
// SSE would send a server Goose refuses to consume.
if rt.AssumeMcpSse {
t.Error("AssumeMcpSse = true, want false (Goose rejects SSE MCP)")
}
}
func TestGooseACP_EnvAndStripEnv(t *testing.T) {
rt := NewGooseACP().Runtime()
// Goose authenticates via ~/.config/goose config files, not provider
// API-key env vars, so there is no behavior-changing env to set or strip.
if len(rt.Env) != 0 {
t.Errorf("Runtime Env = %#v, want empty (no skip/env knob exists; global extensions still load)", rt.Env)
}
if len(rt.StripEnv) != 0 {
t.Errorf("Runtime StripEnv = %#v, want empty (Goose reads config files, not provider env vars)", rt.StripEnv)
}
}
func TestGooseACP_InstallScript(t *testing.T) {
got := NewGooseACP().InstallScript()
for _, needle := range []string{
"set -eu",
`tmp="$(mktemp)"`,
`trap 'rm -f "$tmp"' EXIT`,
"curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh",
`-o "$tmp"`,
"install_dir=",
`GOOSE_BIN_DIR="$install_dir" CONFIGURE=false bash "$tmp"`,
`command -v goose`,
"goose",
} {
if !strings.Contains(got, needle) {
t.Errorf("InstallScript missing %q: %q", needle, got)
}
}
if strings.HasPrefix(got, "npm install -g ") {
t.Errorf("InstallScript should use the native Goose installer, got npm script: %q", got)
}
if !strings.Contains(got, "CONFIGURE=false") {
t.Errorf("InstallScript should set CONFIGURE=false for deterministic non-interactive install, got %q", got)
}
}
func TestGooseACPInstallScriptUsesFirstWritablePathDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("install script is POSIX shell")
}
home := t.TempDir()
binDir := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir fake bin: %v", err)
}
fakeCurl := `#!/bin/sh
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
shift
out="$1"
fi
shift
done
cat > "$out" <<'INSTALL'
#!/bin/sh
set -eu
mkdir -p "$GOOSE_BIN_DIR"
cat > "$GOOSE_BIN_DIR/goose" <<'GOOSE'
#!/bin/sh
echo "Run goose as an ACP agent server on stdio"
GOOSE
chmod +x "$GOOSE_BIN_DIR/goose"
INSTALL
`
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o755); err != nil {
t.Fatalf("write fake curl: %v", err)
}
cmd := exec.Command("sh", "-c", NewGooseACP().InstallScript())
cmd.Env = []string{
"HOME=" + home,
"PATH=" + binDir + ":/usr/bin:/bin",
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("InstallScript failed: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(binDir, "goose")); err != nil {
t.Fatalf("expected goose binary in first writable PATH directory: %v", err)
}
}
func TestGooseACPInstallScriptPropagatesDownloadFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("install script is POSIX shell")
}
binDir := t.TempDir()
fakeCurl := "#!/bin/sh\nexit 1\n"
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o755); err != nil {
t.Fatalf("write fake curl: %v", err)
}
cmd := exec.Command("sh", "-c", NewGooseACP().InstallScript())
cmd.Env = []string{
"HOME=" + t.TempDir(),
"PATH=" + binDir + ":/usr/bin:/bin",
}
if out, err := cmd.CombinedOutput(); err == nil {
t.Fatalf("InstallScript succeeded after curl failure; output: %s", out)
}
}
func TestGooseACP_DetectionRequiresGlobalBinary(t *testing.T) {
if _, err := exec.LookPath("goose"); err == nil {
t.Skip("detection binary \"goose\" is on PATH; can't verify availability requirement")
}
result, err := NewGooseACP().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled error: %v", err)
}
if result.Available {
t.Error("Available=true without goose on PATH; discovery must not imply install")
}
}
func TestGooseACP_DetectionRejectsUnrelatedGooseBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell executable fixture is Unix-specific")
}
binDir := t.TempDir()
goosePath := filepath.Join(binDir, gooseBin)
// Pressly's database-migration CLI also installs a `goose` binary. It may
// answer generic version requests successfully, but it has no Goose ACP
// help surface.
script := "#!/bin/sh\necho 'goose version v3.24.3'\n"
if err := os.WriteFile(goosePath, []byte(script), 0o755); err != nil {
t.Fatalf("write unrelated goose executable: %v", err)
}
t.Setenv("PATH", binDir)
result, err := NewGooseACP().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled error: %v", err)
}
if result.Available {
t.Fatal("Available=true for unrelated goose binary without ACP help")
}
}
func TestGooseACP_DetectionAcceptsACPHelp(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell executable fixture is Unix-specific")
}
binDir := t.TempDir()
goosePath := filepath.Join(binDir, gooseBin)
script := "#!/bin/sh\necho 'Run goose as an ACP agent server on stdio'\n"
if err := os.WriteFile(goosePath, []byte(script), 0o755); err != nil {
t.Fatalf("write Goose executable: %v", err)
}
t.Setenv("PATH", binDir)
result, err := NewGooseACP().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled error: %v", err)
}
if !result.Available {
t.Fatal("Available=false for Goose binary with ACP help")
}
if result.MatchedPath != goosePath {
t.Errorf("MatchedPath = %q, want %q", result.MatchedPath, goosePath)
}
}
func TestGooseACP_SupportsMCPAndResume(t *testing.T) {
if _, err := exec.LookPath("goose"); err != nil {
t.Skip("goose not on PATH; MCP/resume capabilities surface only when detected")
}
result, err := NewGooseACP().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled error: %v", err)
}
if !result.SupportsMCP {
t.Error("SupportsMCP = false, want true (session/new mcpServers)")
}
if !result.Capabilities.SupportsSessionResume {
t.Error("SupportsSessionResume = false, want true (session/load)")
}
}
func TestGooseACP_LogosNonEmpty(t *testing.T) {
a := NewGooseACP()
if len(a.Logo(LogoLight)) == 0 {
t.Error("Logo(LogoLight) is empty")
}
if len(a.Logo(LogoDark)) == 0 {
t.Error("Logo(LogoDark) is empty")
}
if !strings.Contains(string(a.Logo(LogoLight)), "<svg") {
t.Error("Logo(LogoLight) is not SVG")
}
}
func TestGooseACP_RemoteAuth(t *testing.T) {
auth := NewGooseACP().RemoteAuth()
if auth == nil {
t.Fatal("RemoteAuth() returned nil")
}
if len(auth.Methods) != 1 {
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
}
files := auth.Methods[0]
if files.Type != "files" {
t.Errorf("Methods[0].Type = %q, want files", files.Type)
}
if files.TargetRelDir != ".config/goose" {
t.Errorf("TargetRelDir = %q, want .config/goose", files.TargetRelDir)
}
for _, osName := range []string{"darwin", "linux"} {
paths := files.SourceFiles[osName]
want := []string{".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"}
if !slices.Equal(paths, want) {
t.Errorf("SourceFiles[%s] = %#v, want %#v", osName, paths, want)
}
}
}
func TestGooseACP_LoginCommand(t *testing.T) {
cmd := NewGooseACP().LoginCommand()
if cmd == nil {
t.Fatal("LoginCommand() returned nil")
}
want := []string{"goose", "configure"}
if !slices.Equal(cmd.Cmd, want) {
t.Errorf("LoginCommand.Cmd = %#v, want %#v", cmd.Cmd, want)
}
if cmd.Description == "" {
t.Error("LoginCommand.Description is empty")
}
}
func TestGooseACP_SessionConfig(t *testing.T) {
rt := NewGooseACP().Runtime()
if rt.WorkingDir != "{workspace}" {
t.Errorf("WorkingDir = %q, want {workspace}", rt.WorkingDir)
}
sc := rt.SessionConfig
if !sc.NativeSessionResume {
t.Error("NativeSessionResume = false, want true")
}
if sc.NewSessionOnWorkspaceRebind {
t.Error("NewSessionOnWorkspaceRebind = true, want false")
}
if sc.CanRecover == nil || !*sc.CanRecover {
t.Error("CanRecover must be true")
}
if sc.SessionDirTemplate != "{home}/.local/share/goose" {
t.Errorf("SessionDirTemplate = %q, want {home}/.local/share/goose", sc.SessionDirTemplate)
}
if sc.SessionDirTarget != "/root/.local/share/goose" {
t.Errorf("SessionDirTarget = %q, want /root/.local/share/goose", sc.SessionDirTarget)
}
}
func TestGooseACP_PermissionAndBillingDefaults(t *testing.T) {
a := NewGooseACP()
if len(a.PermissionSettings()) != 0 {
t.Errorf("PermissionSettings() = %#v, want empty (agentctl auto-approve is authoritative)", a.PermissionSettings())
}
if got := a.BillingType(); got != usage.BillingTypeAPIKey {
t.Errorf("BillingType() = %q, want %q", got, usage.BillingTypeAPIKey)
}
catalog := CatalogPermissionSettings(a)
auto, ok := catalog[PermissionKeyAutoApprove]
if !ok {
t.Fatal("catalog missing auto_approve")
}
if auto.ApplyMethod != PermissionApplyMethodAgentctlAutoApprove {
t.Errorf("auto_approve ApplyMethod = %q, want agentctl auto-approve", auto.ApplyMethod)
}
}
@@ -0,0 +1 @@
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -162,6 +162,14 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
installViaNpm: false,
sessionDirTemplate: "{home}/.hermes",
}},
{func() Agent { return NewGooseACP() }, acpAgentSpec{
id: "goose-acp", displayName: "Goose", detectBinaries: []string{"goose"},
expectedArgv: []string{"goose", "acp"},
inferenceArgv: []string{"goose", "acp"},
passthroughArgv: []string{"goose"},
installViaNpm: false,
sessionDirTemplate: "{home}/.local/share/goose",
}},
}
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
@@ -86,6 +86,7 @@ func (r *Registry) LoadDefaults() {
agents.NewDevinACP(),
agents.NewGrokACP(),
agents.NewHermesACP(),
agents.NewGooseACP(),
agents.NewAntigravityACP(),
agents.NewMockAgent(),
}
@@ -1188,6 +1188,7 @@ var allowedProbeCommands = map[string]string{
"auggie": "auggie",
"cursor-agent": "cursor-agent",
"devin": "devin",
"goose": "goose",
"grok": "grok",
"hermes": "hermes",
"kimi": "kimi",
+131
View File
@@ -0,0 +1,131 @@
---
created: 2026-09-03
status: in-progress
referenced_runbook: docs/public/add-agent-cli.md
---
# Implementation Plan: Goose (AAIF) ACP Agent Support
## Overview
Add the Goose open-source coding agent (Agentic AI Foundation / AAIF, formerly
Block; repo `aaif-goose/goose`) as a first-class Kandev agent. Users can select
Goose as their coding agent; Kandev detects it, launches it over ACP, wires
permissions and MCP through `session/new`, supports one-shot inference, CLI
passthrough, and session resume.
Goose speaks **ACP over stdio** via `goose acp`, the exact protocol Kandev's
agentctl adapter factory accepts. It is distributed as a **native CLI binary**
(install script `download_cli.sh`, Homebrew `block-goose-cli`, or `pip install
goose-ai`), so it is discovered purely on PATH and uses **no** managed
npm runtime. Config lives under `~/.config/goose`; session/history data under
`~/.local/share/goose`. Credentials come from `goose configure`.
Because Goose is a native-binary ACP agent, this is the runbook's "Ship a
structured ACP integration" path (`docs/public/add-agent-cli.md`), modeled on
`agents/gemini.go` (example) and `agents/agent.go` (contract); Hermes, Qwen,
and Devin are the closest siblings.
## Verified integration contract
Evidence is drawn from the Goose source, an ACP client reference
(`catatafishen/agentbridge#1009`, merged), terminal-transcript detection
(`herdrdev/herdr#3279`), and Paseo's per-agent catalog for positioning.
- **Launch:** `["goose", "acp"]`; passthrough `["goose"]`.
- **Protocol:** `agent.ProtocolACP` (only protocol the adapter factory accepts).
- **MCP:** delivered natively via ACP `session/new` (`mcpServers`); **no**
`ProjectMCPStrategy` needed. Goose supports Stdio and HTTP (StreamableHttp)
MCP but **rejects SSE**; keep `AssumeMcpSse=false`.
- **Developer built-in:** `goose acp` auto-enables Goose's `developer` built-in
extension by default; this is expected and documented.
- **Global extensions:** Goose loads globally-enabled extensions even when
`session/new` MCP servers are passed; there is no skip knob on `goose acp`.
Accepted behavior; a slow unrelated global extension may delay `initialize`.
- **Session resume:** ACP `session/load`; native resume enabled with session
root `{home}/.local/share/goose`.
- **Auth:** declarative config (`~/.config/goose`); primary remote path is a
`files` `RemoteAuth` copy of the config dir; optional `goose configure` PTY
login.
- **Permissions:** Goose presents real interactive approval prompts; map
`PermissionKeyAutoApprove` via `PermissionApplyMethodAgentctlAutoApprove`.
- **Environment:** no required environment variables; Goose reads provider
credentials from its config files.
- **Install script:** Kandev downloads the official script to a temporary file,
runs it with `CONFIGURE=false` in the first writable absolute directory on
the current `PATH`, and verifies the installed `goose acp --help` command.
> **Deviation from earlier plan draft:** `NativeBinaryAgent` is **not**
> implemented. The interface (`agent.go`) is specifically for npm-distributed
> packages that also ship a standalone CLI (e.g. Copilot) to prefer over
> `npx`. Goose is a pure native binary with no npm launch path, so the
> interface does not apply; implementing it would be a meaningless flag.
## Files
### Create
- `apps/backend/internal/agent/agents/goose_acp.go`
- `apps/backend/internal/agent/agents/goose_acp_test.go`
- `apps/backend/internal/agent/agents/logos/goose_light.svg`
- `apps/backend/internal/agent/agents/logos/goose_dark.svg`
- `docs/plans/goose-agent/plan.md` (this document; shipped in the PR as a
reference for reviewers and future contributors)
### Modify
- `apps/backend/internal/agent/registry/registry.go` — add
`agents.NewGooseACP()` to `LoadDefaults()` after `NewHermesACP()`.
- `apps/backend/internal/agent/agents/new_acp_agents_test.go` — add a `GooseACP`
entry to `newACPAgentSpecs` so the shared contract matrix, detection, logos,
and display-order tests cover it.
### Docs
- `docs/public/agents-and-profiles.md` — document Goose as a supported agent
(install, launch via `goose acp`, credentials via `goose configure`) at parity
with peers.
## Steps
1. **Contract pinning (TDD)** — write `goose_acp_test.go` first, pinning:
identity (`goose-acp`, "Goose ACP Agent", "Goose", DisplayOrder 22);
`["goose","acp"]` across BuildCommand/Runtime.Cmd/InferenceConfig.Command;
passthrough `["goose"]`; `Runtime().Protocol == agent.ProtocolACP`;
`SessionDirTemplate == "{home}/.local/share/goose"`; InstallScript; RemoteAuth
files for darwin/linux; no provider environment requirements; logos non-empty;
bounded ACP-help detection.
2. **Implement `goose_acp.go`** — `type GooseACP struct { StandardPassthrough }`;
marker interfaces `Agent`, `PassthroughAgent`, `InferenceAgent`,
`LoginAgent`; `goose` binary via `Cmd`/`Command` builders (no shell strings);
`IsInstalled` via a bounded `goose acp --help` identity check; `Runtime()` with
Protocol ACP, native resume, pinned session dir, `AssumeMcpSse=false`,
no `StripEnv`; `RemoteAuth` file copy; `InstallScript`; `PermissionSettings`;
`InferenceConfig`.
3. **Register** in `registry.go` `LoadDefaults()`.
4. **Test wiring** — add Goose to `new_acp_agents_test.go` matrix; add SVGs.
5. **Validate** — `make -C apps/backend` package tests (agents, registry,
lifecycle, adapter), `make lint-backend`, `make test-backend`, and the
real-adapter `make -C apps/backend test-e2e` (gated, may need a real goose
install / login).
6. **Manual smoke** — `make dev`, confirm Goose appears in the agent/profile
picker, start/prompt/cancel/resume, model/mode refresh, permissions,
`session/new` MCP registration via `acpdbg mcp-probe goose`.
7. **Docs + i18n** — update public docs; any user-facing copy via `t()` / i18n
ratchets (agent name/description surface via the backend DTO, English).
8. **Commit** — single `feat(agents): add Goose ACP agent support` with this plan
doc included.
## Risks
- **ACP wire compatibility:** `goose acp` is documented ACP-compatible and its
stdio entry is stable (low impact). A bridge shim is the fallback if a
mismatch surfaces; verify with a real spawn early.
- **Tool-title humanization (from #1009):** Goose humanizes MCP `tool_call`
titles (`"extension: read file · /arg"`); confirm Kandev's stream layer
handles or normalizes these, else add a small parser.
- **Global extensions auto-load:** accepted; may delay `initialize` if a slow
unrelated extension is configured.
- **Detection:** `goose acp` is a blocking server; use its bounded `--help`
command, not the server itself or a provider-opening probe.
- **Windows support** is unverified upstream; treated as a caveat.
+3 -1
View File
@@ -22,10 +22,12 @@ Open **Settings > Agents** (`/settings/agents`). Kandev scans the host on which
![Settings > Agents showing detected agent CLIs, profiles, configured status, unavailable status, update indicators, and New profile controls.](../screenshots/settings-agents.png)
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, Hermes, and Antigravity. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, Hermes, Goose, and Antigravity. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
Hermes launches with `hermes acp`. Install the required `hermes` executable from its **Settings > Agents** card, which runs the official Hermes installer. Hermes currently supports task and workspace sessions. Office-assigned skill injection is not yet supported.
Goose launches with `goose acp`. Its **Settings > Agents** card runs only the official `download_cli.sh` installer. Homebrew (`block-goose-cli`) and pip (`pip install goose-ai`) are manual alternatives. Configure your model provider with `goose configure`. Goose currently supports task and workspace sessions.
Antigravity has no automated install: Google distributes `agy_acp_server.par` (`agy_acp_server.exe` on Windows) and its `localharness_external` or `localharness` sibling (`localharness_external.exe` or `localharness.exe` on Windows) as a signed archive through the [ACP registry](https://github.com/agentclientprotocol/registry/tree/main/antigravity-acp) rather than npm, so extract both files into one directory and add it to PATH yourself. Kandev fails discovery closed when the harness sibling is missing or not executable, so a partial extraction reports as not installed rather than as a broken session.
### Pi command surfaces