feat(agents): add Muse Code ACP agent support (#3819)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Jean-Emmanuel Losi
2026-09-20 06:33:09 -04:00
committed by GitHub
parent 9e171c8377
commit 02ac02cd66
19 changed files with 786 additions and 11 deletions
@@ -15,6 +15,7 @@ package/default generation resets it during startup.
| Copilot | `@github/copilot` | `--acp` | | Copilot | `@github/copilot` | `--acp` |
| Gemini | `@google/gemini-cli` | `--acp` | | Gemini | `@google/gemini-cli` | `--acp` |
| Pi | `pi-acp` | none | | Pi | `pi-acp` | none |
| Muse | `@bex-co/muse-code-acp` | none |
Normal capability probes, sessions, container commands, and one-shot inference Normal capability probes, sessions, container commands, and one-shot inference
use `npx --yes --prefer-offline package@<effective-version>` with the ACP use `npx --yes --prefer-offline package@<effective-version>` with the ACP
@@ -234,7 +234,7 @@ func TestAntigravityACP_DisplayOrderUnique(t *testing.T) {
NewIFlowACP(), NewDroidACP(), NewKilocodeACP(), NewPiACP(), NewIFlowACP(), NewDroidACP(), NewKilocodeACP(), NewPiACP(),
NewCursorACP(), NewKimiACP(), NewKiroACP(), NewQoderACP(), NewCursorACP(), NewKimiACP(), NewKiroACP(), NewQoderACP(),
NewTraeACP(), NewOmpACP(), NewDevinACP(), NewGrokACP(), NewTraeACP(), NewOmpACP(), NewDevinACP(), NewGrokACP(),
NewHermesACP(), NewAntigravityACP(), NewMockAgent(), NewHermesACP(), NewAntigravityACP(), NewMuseACP(), NewMockAgent(),
} }
seen := map[int]string{} seen := map[int]string{}
for _, ag := range all { for _, ag := range all {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><title>Muse</title><path d="M4 19V5l8 9 8-9v14"/></svg>

After

Width:  |  Height:  |  Size: 211 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><title>Muse</title><path d="M4 19V5l8 9 8-9v14"/></svg>

After

Width:  |  Height:  |  Size: 211 B

@@ -22,6 +22,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
{"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}}, {"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}},
{"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}}, {"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}},
{"pi", NewPiACP(), "pi-acp", nil}, {"pi", NewPiACP(), "pi-acp", nil},
{"muse", NewMuseACP(), "@bex-co/muse-code-acp", nil},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -153,6 +154,7 @@ func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) {
{"copilot", NewCopilotACP()}, {"copilot", NewCopilotACP()},
{"gemini", NewGemini()}, {"gemini", NewGemini()},
{"pi", NewPiACP()}, {"pi", NewPiACP()},
{"muse", NewMuseACP()},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -1,6 +1,7 @@
{ {
"@agentclientprotocol/claude-agent-acp": "0.76.0", "@agentclientprotocol/claude-agent-acp": "0.76.0",
"@agentclientprotocol/codex-acp": "1.11.0", "@agentclientprotocol/codex-acp": "1.11.0",
"@bex-co/muse-code-acp": "0.6.1",
"@github/copilot": "1.0.83", "@github/copilot": "1.0.83",
"@google/gemini-cli": "0.59.0", "@google/gemini-cli": "0.59.0",
"opencode-ai": "1.18.30", "opencode-ai": "1.18.30",
@@ -0,0 +1,216 @@
//nolint:dupl,goconst // Native-binary ACP agents (Goose, 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"
"runtime"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/muse_acp_light.svg
var museACPLogoLight []byte
//go:embed logos/muse_acp_dark.svg
var museACPLogoDark []byte
const (
museACPPkg = "@bex-co/muse-code-acp"
museBin = "muse"
)
var (
_ Agent = (*MuseACP)(nil)
_ PassthroughAgent = (*MuseACP)(nil)
_ InferenceAgent = (*MuseACP)(nil)
_ LoginAgent = (*MuseACP)(nil)
_ ManagedNPMRuntimeAgent = (*MuseACP)(nil)
)
// MuseACP implements Agent for Meta's Muse Code. Muse has no native ACP
// server: it speaks the Muse Session Protocol through `muse serve`. The
// community @bex-co/muse-code-acp adapter bridges MSP to ACP over stdio and
// spawns the native `muse` binary it finds on PATH (or MUSE_CODE_EXECUTABLE),
// so structured sessions run the managed npm adapter while installation,
// login, and CLI passthrough target the native `muse` binary.
type MuseACP struct {
StandardPassthrough
}
func NewMuseACP() *MuseACP {
return &MuseACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(museBin),
ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *MuseACP) ID() string { return "muse-acp" }
func (a *MuseACP) Name() string { return "Muse Code ACP" }
func (a *MuseACP) DisplayName() string { return "Muse" }
func (a *MuseACP) Description() string {
return "Meta Muse Code using the ACP protocol via the @bex-co/muse-code-acp adapter."
}
func (a *MuseACP) Enabled() bool { return true }
func (a *MuseACP) DisplayOrder() int { return 23 }
func (a *MuseACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return museACPLogoDark
}
return museACPLogoLight
}
func (a *MuseACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// The adapter carries no Muse executable; it needs the native `muse`
// binary that owns model access, sandboxing, and persistence. A
// non-interactive version check filters unrelated tools with the same
// name. The ACP capability probe validates the adapter separately.
result, err := Detect(ctx, WithCommandCheck(museBin, "--version"))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *MuseACP) BuildCommand(opts CommandOptions) Command {
return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion)
}
func (a *MuseACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return newManagedNPMRuntimeSpec(museACPPkg)
}
func (a *MuseACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: a.ManagedNPMRuntime().CachedACPCommand(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: DefaultResourceLimits,
Protocol: agent.ProtocolACP,
// Muse resolves both config and data through XDG variables. Kandev
// seeds the default paths into one isolated executor home, so remove
// profile and executor overrides before starting the adapter.
StripEnv: []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME"},
// The adapter forwards ACP session/new MCP servers (stdio and HTTP)
// into a temporary Muse config overlay, so no ProjectMCPStrategy.
SessionConfig: SessionConfig{
// Muse persists native sessions under ~/.local/share/muse by
// default. Mount the isolated executor home so both this data
// tree and ~/.config/muse/auth.json remain visible.
SessionDirTemplate: "{home}",
SessionDirTarget: "/root",
NativeSessionResume: true,
CanRecover: &canRecover,
},
}
}
func (a *MuseACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: remoteAuthMethodTypeFiles,
Label: "Copy Muse auth file",
// `muse login` stores credentials in
// $XDG_CONFIG_HOME/muse/auth.json (default ~/.config).
SourceFiles: map[string][]string{
"darwin": {".config/muse/auth.json"},
"linux": {".config/muse/auth.json"},
},
TargetRelDir: ".config/muse",
},
{
// META_API_KEY takes priority over stored auth in both Muse
// and the adapter; it is the headless credential.
Type: "env",
EnvVar: "META_API_KEY",
},
},
}
}
// muse login runs the browser sign-in and stores credentials in
// ~/.config/muse/auth.json.
func (a *MuseACP) LoginCommand() *LoginCommand {
return &LoginCommand{
Cmd: []string{"env", "-u", "XDG_CONFIG_HOME", "-u", "XDG_DATA_HOME", museBin, "login"},
Description: "Sign in to Muse Code with your Meta account.",
}
}
func (a *MuseACP) InstallScript() string {
if runtime.GOOS == "windows" {
// The official installer is POSIX shell. Returning no script hides
// the install action on Windows instead of sending it to cmd.exe.
return ""
}
// Keep the installer in a temporary file so curl failures cannot be hidden
// by a successful bash exit status. MUSE_INSTALL_DIR points the official
// installer at the first writable absolute directory already on PATH, so
// the parent Kandev process can discover `muse` immediately afterwards.
// The ACP adapter itself is fetched on demand by npx.
return `set -eu
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL https://dev.meta.ai/install.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 Muse" >&2
exit 1
fi
MUSE_INSTALL_DIR="$install_dir" bash "$tmp"
command -v muse >/dev/null 2>&1
resolved="$(command -v muse)"
if [ "$resolved" != "$install_dir/muse" ]; then
echo "Error: Muse installed at $install_dir/muse but another muse is earlier on PATH" >&2
exit 1
fi
"$install_dir/muse" --version >/dev/null 2>&1`
}
func (a *MuseACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *MuseACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: a.ManagedNPMRuntime().CachedACPCommand(),
}
}
func (a *MuseACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -0,0 +1,137 @@
package agents
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)
// Identity, every command surface, detection, logos, and the session-dir
// template are pinned by the shared newACPAgentSpecs matrix; the managed npm
// package contract by TestManagedNPMRuntimeContracts. These cover what is
// Muse-specific: the native-binary install, credentials, and resume paths.
func TestMuseACP_InstallScriptUsesOfficialInstallerInFirstWritablePathDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("install script is POSIX shell")
}
script := NewMuseACP().InstallScript()
for _, needle := range []string{
"curl -fsSL https://dev.meta.ai/install.sh",
`MUSE_INSTALL_DIR="$install_dir" bash "$tmp"`,
} {
if !strings.Contains(script, needle) {
t.Errorf("InstallScript missing %q", needle)
}
}
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 "$MUSE_INSTALL_DIR"
cat > "$MUSE_INSTALL_DIR/muse" <<'MUSE'
#!/bin/sh
echo "muse 1.2.1"
MUSE
chmod +x "$MUSE_INSTALL_DIR/muse"
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", script)
cmd.Env = []string{"HOME=" + t.TempDir(), "PATH=" + binDir + ":/usr/bin:/bin"}
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("InstallScript failed: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(binDir, "muse")); err != nil {
t.Fatalf("expected muse binary in first writable PATH directory: %v", err)
}
}
func TestMuseACP_InstallScriptPropagatesDownloadFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("install script is POSIX shell")
}
binDir := t.TempDir()
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatalf("write fake curl: %v", err)
}
cmd := exec.Command("sh", "-c", NewMuseACP().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 TestMuseACP_RemoteAuth(t *testing.T) {
auth := NewMuseACP().RemoteAuth()
if auth == nil || len(auth.Methods) != 2 {
t.Fatalf("RemoteAuth() = %+v, want files + env methods", auth)
}
files := auth.Methods[0]
if files.Type != remoteAuthMethodTypeFiles || files.TargetRelDir != ".config/muse" {
t.Errorf("Methods[0] = %+v, want files into .config/muse", files)
}
for _, osName := range []string{"darwin", "linux"} {
if got, want := files.SourceFiles[osName], []string{".config/muse/auth.json"}; !slices.Equal(got, want) {
t.Errorf("SourceFiles[%s] = %#v, want %#v", osName, got, want)
}
}
if env := auth.Methods[1]; env.Type != "env" || env.EnvVar != "META_API_KEY" {
t.Errorf("Methods[1] = %+v, want env META_API_KEY", env)
}
}
func TestMuseACP_LoginCommand(t *testing.T) {
cmd := NewMuseACP().LoginCommand()
if cmd == nil || !slices.Equal(cmd.Cmd, []string{"env", "-u", "XDG_CONFIG_HOME", "-u", "XDG_DATA_HOME", "muse", "login"}) {
t.Fatalf("LoginCommand() = %+v, want Muse login with XDG overrides removed", cmd)
}
}
func TestMuseACP_PassthroughModelFlag(t *testing.T) {
flag := NewMuseACP().PassthroughConfig().ModelFlag
if got, want := flag.Args(), []string{"--model", "{model}"}; !slices.Equal(got, want) {
t.Errorf("ModelFlag = %#v, want %#v", got, want)
}
}
func TestMuseACP_SessionConfigAndMCPDelivery(t *testing.T) {
rt := NewMuseACP().Runtime()
sc := rt.SessionConfig
if !sc.NativeSessionResume || sc.CanRecover == nil || !*sc.CanRecover {
t.Errorf("SessionConfig = %+v, want native resume and recovery", sc)
}
if sc.SessionDirTarget != "/root" {
t.Errorf("SessionDirTarget = %q, want /root", sc.SessionDirTarget)
}
if !slices.Equal(rt.StripEnv, []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME"}) {
t.Errorf("Runtime.StripEnv = %v, want XDG overrides removed", rt.StripEnv)
}
// The adapter forwards session/new MCP servers itself; a project file
// strategy would duplicate them.
if rt.ProjectMCPStrategy != nil {
t.Errorf("ProjectMCPStrategy = %#v, want nil", rt.ProjectMCPStrategy)
}
}
@@ -26,13 +26,14 @@ type acpAgentSpec struct {
// to skip the test whenever ANY of these is on PATH — otherwise an // to skip the test whenever ANY of these is on PATH — otherwise an
// agent with multiple WithCommand fallbacks flakes when a secondary binary // agent with multiple WithCommand fallbacks flakes when a secondary binary
// is present. // is present.
detectBinaries []string detectBinaries []string
expectedArgv []string // BuildCommand and Runtime.Cmd expectedArgv []string // BuildCommand and Runtime.Cmd
inferenceArgv []string // InferenceConfig.Command inferenceArgv []string // InferenceConfig.Command
passthroughArgv []string // PassthroughCmd (zero-args allowed) passthroughArgv []string // PassthroughCmd (zero-args allowed)
installViaNpm bool // InstallScript starts with "npm install -g" installViaNpm bool // InstallScript starts with "npm install -g"
installScript string // expected InstallScript() value (empty = unchecked) installScript string // expected InstallScript() value (empty = unchecked)
stripEnv []string // expected Runtime().StripEnv (nil = unchecked) skipInstallBinaryCheck bool // installer is intentionally unavailable on this platform
stripEnv []string // expected Runtime().StripEnv (nil = unchecked)
// sessionDirTemplate is the agent's on-disk session root. Pinned per // sessionDirTemplate is the agent's on-disk session root. Pinned per
// agent because it is derived from what the CLI actually writes (see the // agent because it is derived from what the CLI actually writes (see the
// evidence comment on each Runtime()), not from a naming convention — // evidence comment on each Runtime()), not from a naming convention —
@@ -170,6 +171,16 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
installViaNpm: false, installViaNpm: false,
sessionDirTemplate: "{home}/.local/share/goose", sessionDirTemplate: "{home}/.local/share/goose",
}}, }},
{func() Agent { return NewMuseACP() }, acpAgentSpec{
id: "muse-acp", displayName: "Muse", detectBinaries: []string{"muse"},
expectedArgv: []string{"npx", "--yes", "--prefer-offline", "@bex-co/muse-code-acp@0.6.1"},
inferenceArgv: []string{"npx", "--yes", "--prefer-offline", "@bex-co/muse-code-acp@0.6.1"},
passthroughArgv: []string{"muse"},
installViaNpm: false,
skipInstallBinaryCheck: true,
stripEnv: []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME"},
sessionDirTemplate: "{home}",
}},
} }
func TestNewACPAgents_IDAndDisplay(t *testing.T) { func TestNewACPAgents_IDAndDisplay(t *testing.T) {
@@ -274,7 +285,7 @@ func TestNewACPAgents_SessionDirTemplate(t *testing.T) {
if got := rt.SessionConfig.SessionDirTemplate; got != tc.spec.sessionDirTemplate { if got := rt.SessionConfig.SessionDirTemplate; got != tc.spec.sessionDirTemplate {
t.Errorf("SessionDirTemplate = %q, want %q", got, tc.spec.sessionDirTemplate) t.Errorf("SessionDirTemplate = %q, want %q", got, tc.spec.sessionDirTemplate)
} }
if !strings.HasPrefix(tc.spec.sessionDirTemplate, "{home}/") { if tc.spec.sessionDirTemplate != "{home}" && !strings.HasPrefix(tc.spec.sessionDirTemplate, "{home}/") {
t.Errorf("SessionDirTemplate %q must be {home}-relative; SessionDirHostPath only trims that prefix", t.Errorf("SessionDirTemplate %q must be {home}-relative; SessionDirHostPath only trims that prefix",
tc.spec.sessionDirTemplate) tc.spec.sessionDirTemplate)
} }
@@ -303,6 +314,9 @@ func TestNewACPAgents_InstallScript(t *testing.T) {
// to Install" status see a hint that resolves to the right // to Install" status see a hint that resolves to the right
// command. Only check the first detect binary; secondaries are // command. Only check the first detect binary; secondaries are
// fallbacks that may not appear in either surface. // fallbacks that may not appear in either surface.
if tc.spec.skipInstallBinaryCheck {
return
}
primary := tc.spec.detectBinaries[0] primary := tc.spec.detectBinaries[0]
argv := ag.BuildCommand(CommandOptions{}).Args() argv := ag.BuildCommand(CommandOptions{}).Args()
if !slices.Contains(argv, primary) && !strings.Contains(got, primary) { if !slices.Contains(argv, primary) && !strings.Contains(got, primary) {
@@ -87,6 +87,7 @@ func (r *Registry) LoadDefaults() {
agents.NewGrokACP(), agents.NewGrokACP(),
agents.NewHermesACP(), agents.NewHermesACP(),
agents.NewGooseACP(), agents.NewGooseACP(),
agents.NewMuseACP(),
agents.NewAntigravityACP(), agents.NewAntigravityACP(),
agents.NewMockAgent(), agents.NewMockAgent(),
} }
@@ -416,6 +416,7 @@ func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.
{"gemini", agents.NewGemini()}, {"gemini", agents.NewGemini()},
{"auggie", agents.NewAuggie()}, {"auggie", agents.NewAuggie()},
{"grok-acp", agents.NewGrokACP()}, {"grok-acp", agents.NewGrokACP()},
{"muse-acp", agents.NewMuseACP()},
} }
const kandevHome = "/tmp/kandev-test-home" const kandevHome = "/tmp/kandev-test-home"
const instanceID = "0123456789abcdef" const instanceID = "0123456789abcdef"
@@ -471,6 +472,49 @@ func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.
} }
} }
func TestBuildContainerConfig_MuseMountContainsSeededAuthAndSessionData(t *testing.T) {
cm := newCMTest(t)
cm.kandevHomeDir = "/tmp/kandev-test-home"
const instanceID = "muse-instance"
ag := agents.NewMuseACP()
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: ag,
InstanceID: instanceID,
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
root := filepath.Join(cm.kandevHomeDir, "agent-sessions", instanceID)
var mount *docker.MountConfig
for i := range got.Mounts {
if got.Mounts[i].Target == "/root" {
mount = &got.Mounts[i]
break
}
}
if mount == nil {
t.Fatalf("expected Muse executor-home mount at /root, got %+v", got.Mounts)
}
if mount.Source != root {
t.Fatalf("Muse mount source = %q, want isolated executor root %q", mount.Source, root)
}
auth := ag.RemoteAuth()
if auth == nil || len(auth.Methods) == 0 {
t.Fatal("Muse must declare remote auth files")
}
seededAuth := filepath.Join(root, auth.Methods[0].TargetRelDir, "auth.json")
if !strings.HasPrefix(seededAuth, mount.Source+string(filepath.Separator)) {
t.Fatalf("seeded auth path %q is outside Muse mount %q", seededAuth, mount.Source)
}
if !strings.HasPrefix(filepath.Join(root, ".local", "share", "muse"), mount.Source+string(filepath.Separator)) {
t.Fatalf("Muse session path is outside Muse mount %q", mount.Source)
}
}
func TestBuildContainerConfig_MountsDevinCredentialSessionDir(t *testing.T) { func TestBuildContainerConfig_MountsDevinCredentialSessionDir(t *testing.T) {
cm := newCMTest(t) cm := newCMTest(t)
cm.kandevHomeDir = "/tmp/kandev-test-home" cm.kandevHomeDir = "/tmp/kandev-test-home"
@@ -15,6 +15,17 @@ import {
const INCIDENT_LOG_LINE_COUNT = 3_921; const INCIDENT_LOG_LINE_COUNT = 3_921;
test.beforeEach(async ({ backend }) => {
// This test asserts the queue UI. Pin the feature off so a steering-enabled
// shard cannot change the input mode while the test is running.
await backend.restart({ KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "false" });
});
test.afterEach(async ({ backend }) => {
// Restore the worker baseline for the next test in the shard.
await backend.restart();
});
function oversizedMessage(prefix: string): { source: string; tail: string; firstLine: string } { function oversizedMessage(prefix: string): { source: string; tail: string; firstLine: string } {
const tail = `${prefix}-TAIL-MARKER`; const tail = `${prefix}-TAIL-MARKER`;
const logLine = (index: number) => const logLine = (index: number) =>
@@ -116,3 +116,33 @@ describe("setSessionCommits — empty-response guard", () => {
expect(after[0].commit_sha).toBe("new"); expect(after[0].commit_sha).toBe("new");
}); });
}); });
describe("addSessionCommit — duplicate notifications", () => {
let useStore: ReturnType<typeof makeStore>;
beforeEach(() => {
useStore = makeStore();
});
it("updates a commit from a live notification without duplicating a fetched snapshot", () => {
useStore
.getState()
.setSessionCommits(SESSION, [
commit({ commit_sha: "head", parent_sha: "middle" }),
commit({ commit_sha: "middle", parent_sha: "base", insertions: 1, pushed: true }),
]);
useStore
.getState()
.addSessionCommit(
SESSION,
commit({ commit_sha: "middle", parent_sha: "base", insertions: 7 }),
);
const commits = useStore.getState().sessionCommits.byEnvironmentId[SESSION];
expect(commits).toHaveLength(2);
expect(commits?.map(({ commit_sha }) => commit_sha)).toEqual(["head", "middle"]);
expect(commits?.[1].insertions).toBe(7);
expect(commits?.[1].pushed).toBe(true);
});
});
@@ -227,6 +227,19 @@ function buildSessionCommitActions(set: ImmerSet) {
set((draft) => { set((draft) => {
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
const existing = draft.sessionCommits.byEnvironmentId[envKey] || []; const existing = draft.sessionCommits.byEnvironmentId[envKey] || [];
const duplicateIndex = existing.findIndex(
(current) =>
current.commit_sha === commit.commit_sha &&
(current.repository_name ?? "") === (commit.repository_name ?? ""),
);
// A commit_created notification can arrive again while a refetch is
// replacing the list. Update the fetched row in place so the same
// commit is never rendered twice.
if (duplicateIndex >= 0) {
existing[duplicateIndex] = { ...existing[duplicateIndex], ...commit };
draft.sessionCommits.byEnvironmentId[envKey] = existing;
return;
}
// For amend: only replace HEAD (first entry) if it has the same parent // For amend: only replace HEAD (first entry) if it has the same parent
if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) { if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) {
existing[0] = commit; existing[0] = commit;
+100
View File
@@ -0,0 +1,100 @@
---
created: 2026-09-19
status: in-progress
requirements:
- REQ-AGENTS-MUSE-ACP-001
- REQ-AGENTS-MUSE-ACP-002
- REQ-AGENTS-MUSE-ACP-003
system_design:
- ../../specs/agents/system-design/muse-acp-agent.md
---
# Implementation Plan: Muse Code ACP Agent Support
## Overview
Add Meta's Muse Code CLI (released 2026-08-05, backed by Muse Spark) as a
built-in Kandev agent with structured chat, tool calls, MCP, one-shot
inference, CLI passthrough, and session resume.
Muse has **no native ACP server**. Its structured surface is the Muse Session
Protocol (MSP) served by `muse serve`. Per the runbook ("If the upstream CLI
has no ACP server, add or reuse a bridge that speaks ACP"), this reuses the
community `@bex-co/muse-code-acp` adapter (Apache-2.0, built on the official
`@muse-code/sdk`), which bridges MSP to ACP over stdio. The adapter is
npm-distributed, so the launch follows the Pi shape: a managed npm runtime for
structured sessions and inference, and the native `muse` binary for
detection, install, login, and passthrough.
## Integration contract
- **Structured launch:** `npx --yes --prefer-offline
@bex-co/muse-code-acp@<effective-version>`, no arguments; pinned in
`managed_npm_runtime_versions.json` and kept current by
`update-agent-runtime-pins`. Requires Node.js 22+.
- **Passthrough:** `["muse"]` with `--model {model}`. No passthrough MCP
strategy (as with Goose).
- **Detection:** `muse --version` on PATH. The adapter ships no Muse binary,
so an adapter-only host is correctly reported as not installed.
- **MCP:** delivered through ACP `session/new`; the adapter writes stdio and
HTTP servers into a temporary Muse config overlay. No `ProjectMCPStrategy`.
- **Session resume:** native, via the adapter's `session/load`/resume over
`muse serve`. Muse uses the default `~/.local/share/muse` path. Kandev
removes `XDG_CONFIG_HOME` and `XDG_DATA_HOME` from Muse processes and mounts
the isolated `{home}` executor directory at `/root`, so credentials and
sessions stay inside Kandev-managed storage.
- **Auth:** `muse login` (browser) writes `~/.config/muse/auth.json`, copied
by the `files` remote-auth method. `META_API_KEY` is the headless `env`
method and takes priority over stored auth.
- **Install script:** on POSIX hosts, downloads `https://dev.meta.ai/install.sh`
to a temporary file and runs it with `MUSE_INSTALL_DIR` set to the first
writable absolute PATH directory, then verifies `muse --version`. Windows
exposes no automated install action because the installer is POSIX shell.
- **Permissions:** empty settings; the adapter forwards Muse's approval
requests (including per-stage shell approvals) to the ACP client, where
agentctl's auto-approve policy applies.
- **Models and modes:** discovered by the host-utility probe from the
adapter's `session/new` config options; no static list.
## Live verification
Verified on macOS arm64 with Muse Code 1.3.0 (1.3.0-R3401.1) and adapter
0.6.1, through `acpdbg` and a raw ACP client:
- `acpdbg probe muse-acp`: initialize, `session/new`, one model and five modes
(`default`, `readOnly`, `plan`, `bypassApprovals`, `rejectApprovals`).
- `acpdbg prompt`: a turn that created a workspace file with a shell tool
call and ended with `end_turn`.
- A stdio MCP server passed in `session/new` was started and its tool called
(`mcp__everything__echo`). `acpdbg mcp-probe` reports the HTTP sentinel
as unobserved because Muse connects MCP servers only when a turn starts.
- `acpdbg session-load`: history replayed and a follow-up turn recalled the
earlier turn.
- The adapter uses `model` from `~/.config/muse/settings.json`, otherwise its
built-in `muse-spark-1.2`; native `muse` requires `schema_version` in that
file and otherwise uses the provider catalogue default.
## Known limitations
- The adapter is community-maintained and not affiliated with Meta. Its
verified Muse host versions are 1.1.1 and 1.2.1; the Muse launcher
self-updates, so a newer host can drift from the adapter's pinned MSP
schema (the adapter reports this as an advisory `hostCompatibility` entry).
- Token usage and delegated workers are not reported by the adapter.
- Windows has no automated Muse installation because the official installer is
POSIX shell.
## Work orders
- [Task 01: Add the Muse Code ACP agent](task-01-add-muse-acp-agent.md)
## Verification
- Unit: shared `newACPAgentSpecs` matrix (IDs, every command surface,
detection, logos, session dir), `TestManagedNPMRuntimeContracts`, and
`muse_acp_test.go` (installer behaviour, remote auth, login, resume/MCP).
Container configuration also verifies that the isolated Muse mount contains
both seeded credentials and native session data.
- Manual: Settings > Agents detection, profile creation, a structured task
with tool calls and an MCP tool, resume after restart, model/mode refresh,
and passthrough.
@@ -0,0 +1,46 @@
---
id: "01-add-muse-acp-agent"
title: "Add the Muse Code ACP agent"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-AGENTS-MUSE-ACP-001
- REQ-AGENTS-MUSE-ACP-002
- REQ-AGENTS-MUSE-ACP-003
acceptance_criteria:
- AC-AGENTS-MUSE-ACP-001.1
- AC-AGENTS-MUSE-ACP-001.2
- AC-AGENTS-MUSE-ACP-002.1
- AC-AGENTS-MUSE-ACP-002.2
- AC-AGENTS-MUSE-ACP-002.3
- AC-AGENTS-MUSE-ACP-003.1
- AC-AGENTS-MUSE-ACP-003.2
- AC-AGENTS-MUSE-ACP-003.3
system_design:
- ../../specs/agents/system-design/muse-acp-agent.md
---
# Task 01: Add the Muse Code ACP Agent
## Summary
Add `agents.MuseACP`, register it, pin the adapter in the managed npm runtime
catalogue, and document the command surfaces.
## In scope
- `muse_acp.go`, logos, registry entry, catalogue pin, bridge-version table.
- Shared ACP agent matrix, managed-runtime contract, and Muse-specific tests.
- Public agent docs.
## Out of scope
- Implementing MSP in Kandev, Windows installation, and passthrough MCP.
## Acceptance
`go test ./internal/agent/agents ./internal/agent/registry` and
`make lint-backend` pass; a live Muse host is detected, runs a structured task
with a tool call, and resumes it.
+33 -2
View File
@@ -24,7 +24,7 @@ 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) ![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, 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. 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, Muse, 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. 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.
@@ -32,6 +32,37 @@ Goose launches with `goose acp`. Its **Settings > Agents** card runs only the of
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. 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.
### Muse command surfaces
Muse Code has no native ACP server, so Kandev runs it through the community
[`@bex-co/muse-code-acp`](https://github.com/bex-co/muse-code-acp) adapter,
which is not affiliated with Meta:
- Structured ACP sessions and one-shot inference use
`npx --yes --prefer-offline @bex-co/muse-code-acp@<effective-version>`, which
needs Node.js 22 or later.
- The adapter drives the native `muse` executable through `muse serve`; CLI
Passthrough starts `muse` directly.
- On POSIX hosts, the Muse install action runs the official
`https://dev.meta.ai/install.sh` installer into the first writable directory
on the backend's `PATH`. Windows has no automated Muse install action because
the official installer is a POSIX shell script.
Kandev detects Muse when `muse` is on the `PATH` of the backend process and
responds to `--version`. Sign in with `muse login`, or set `META_API_KEY` for
headless and remote executors. Muse keeps sessions under
`~/.local/share/muse`, which Kandev uses for session resume.
Kandev removes `XDG_CONFIG_HOME` and `XDG_DATA_HOME` overrides from Muse
processes so credentials and sessions use these managed paths. Executors use an
isolated home directory for both `~/.config/muse` and `~/.local/share/muse`; the
real host home directory is not mounted.
The adapter starts sessions on the `model` in Muse's
`~/.config/muse/settings.json` and falls back to its own default otherwise,
while the interactive `muse` CLI falls back to Meta's catalogue default. To
pin one model for both, set it there, for example
`{"schema_version": 1, "model": "muse-spark-1.3"}`.
### Pi command surfaces ### Pi command surfaces
Pi uses separate executables for its two Kandev modes: Pi uses separate executables for its two Kandev modes:
@@ -56,7 +87,7 @@ The status shown on this page is authoritative for the current host. A CLI that
### Update a managed agent runtime ### Update a managed agent runtime
The update icon is available on managed Claude, Codex, OpenCode, Copilot, The update icon is available on managed Claude, Codex, OpenCode, Copilot,
Gemini, and Pi agent cards. It updates the runtime on the Kandev host. Gemini, Pi, and Muse agent cards. It updates the runtime on the Kandev host.
Each managed runtime has a reviewed Kandev default. If you have not selected a Each managed runtime has a reviewed Kandev default. If you have not selected a
version, Kandev uses that exact default for probes, sessions, standalone version, Kandev uses that exact default for probes, sessions, standalone
@@ -0,0 +1,70 @@
---
status: draft
system: agents
created: 2026-09-19
owners:
- kandev
---
# Muse Code ACP agent requirements
## Overview
Meta's Muse Code CLI speaks the Muse Session Protocol through `muse serve`, not
ACP. Kandev needs it as a built-in agent so a user can select it for a task,
run and resume a structured session, and use MCP servers, without Kandev
implementing MSP itself. The integration reuses the community
`@bex-co/muse-code-acp` MSP-to-ACP adapter as a managed npm runtime and targets
the native `muse` executable for discovery, install, login, and passthrough.
## Requirements
### REQ-AGENTS-MUSE-ACP-001: Built-in Muse agent identity and discovery
**Intent:** Give Muse a stable catalog identity and report it available only
when the executable the adapter drives is present.
#### Acceptance criteria
- **AC-AGENTS-MUSE-ACP-001.1:** The agent identifier shall be `muse-acp`, the
display name `Muse`, and the catalog name `Muse Code ACP`, with a display
order unique among built-in agents.
- **AC-AGENTS-MUSE-ACP-001.2:** Discovery shall report the agent available only
when `muse` is on the backend PATH and answers `--version`; an adapter-only
host shall report it unavailable.
### REQ-AGENTS-MUSE-ACP-002: Structured and passthrough launch surfaces
**Intent:** Keep structured execution on the pinned adapter and terminal
passthrough on the native CLI.
#### Acceptance criteria
- **AC-AGENTS-MUSE-ACP-002.1:** Structured sessions and one-shot inference shall
launch `npx --yes --prefer-offline @bex-co/muse-code-acp@<effective-version>`
with no extra arguments over `agent.ProtocolACP`, with the default version
pinned in the managed npm runtime catalogue.
- **AC-AGENTS-MUSE-ACP-002.2:** CLI passthrough shall launch `muse`, passing a
selected model as `--model <model>`.
- **AC-AGENTS-MUSE-ACP-002.3:** MCP servers shall be delivered through ACP
`session/new`; the agent shall declare no project MCP file strategy.
### REQ-AGENTS-MUSE-ACP-003: Credentials, install, and resume
**Intent:** Use Muse's own credential, install, and session-storage mechanisms.
#### Acceptance criteria
- **AC-AGENTS-MUSE-ACP-003.1:** Remote authentication shall offer copying
`~/.config/muse/auth.json` and setting `META_API_KEY`; login shall run
`muse login`.
- **AC-AGENTS-MUSE-ACP-003.2:** On POSIX hosts, the install script shall run the
official `https://dev.meta.ai/install.sh` into the first writable absolute
PATH directory, fail when the download fails, and verify `muse --version`.
Windows shall expose no automated install action because the official
installer is a POSIX shell script.
- **AC-AGENTS-MUSE-ACP-003.3:** Native session resume shall use Muse's default
`~/.local/share/muse` path. Kandev shall remove `XDG_CONFIG_HOME` and
`XDG_DATA_HOME` overrides from Muse processes and mount an isolated executor
home so both `~/.config/muse` credentials and `~/.local/share/muse` sessions
are available without mounting the real host home.
@@ -0,0 +1,56 @@
---
status: current
system: agents
requirements:
- REQ-AGENTS-MUSE-ACP-001
- REQ-AGENTS-MUSE-ACP-002
- REQ-AGENTS-MUSE-ACP-003
---
# Muse Code ACP agent system design
## Purpose and boundaries
`agents.MuseACP` declares Muse's identity, discovery, launch, credential, and
session-storage contract. MSP-to-ACP translation belongs to the external
`@bex-co/muse-code-acp` adapter; model access, sandboxing, approvals, and
persistence belong to the native `muse` executable the adapter spawns through
`muse serve`. Kandev adds no MSP code.
## Components
| Surface | Command | Owner |
|---|---|---|
| Structured session, inference, capability probe | `npx --yes --prefer-offline @bex-co/muse-code-acp@<version>` | Managed npm runtime |
| Passthrough, login, discovery | `muse`, `muse login`, `muse --version` | Native Muse CLI |
| Install on POSIX | official `install.sh` with `MUSE_INSTALL_DIR` | Muse installer |
The adapter is registered in `managed_npm_runtime_versions.json`, so the
existing pin updater, cache repair, and operator version selection apply to it
without new code. The `npx` probe command is already allow-listed.
## Data and contracts
- MCP: ACP `session/new` servers (stdio and HTTP) are materialized by the
adapter into a temporary Muse config overlay; no Kandev project file.
- Credentials: `~/.config/muse/auth.json` or `META_API_KEY`, which takes
priority. Muse launch and login processes remove `XDG_CONFIG_HOME` and
`XDG_DATA_HOME` so these default paths remain stable.
- Sessions: `~/.local/share/muse/sessions`, resumed natively by the adapter.
The executor mounts the isolated `{home}` directory at `/root`, which covers
both the credential and session trees without mounting the real host home.
## Failure and recovery
A missing `muse` executable makes discovery report unavailable before launch.
An unauthenticated host fails the ACP session with the adapter's auth error;
the user runs `muse login` or sets `META_API_KEY`. A Muse host newer than the
adapter's verified schema is reported by the adapter as an advisory
`hostCompatibility` session-info entry and does not block the session.
## Security
The install script downloads to a temporary file so a failed download fails
the job, installs only into an existing writable absolute PATH directory, and
embeds no credentials. Windows does not expose the POSIX install script. Remote
auth copies only `auth.json`.