diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index 43ad855f60..9405d57847 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -15,6 +15,7 @@ package/default generation resets it during startup. | Copilot | `@github/copilot` | `--acp` | | Gemini | `@google/gemini-cli` | `--acp` | | Pi | `pi-acp` | none | +| Muse | `@bex-co/muse-code-acp` | none | Normal capability probes, sessions, container commands, and one-shot inference use `npx --yes --prefer-offline package@` with the ACP diff --git a/apps/backend/internal/agent/agents/antigravity_acp_test.go b/apps/backend/internal/agent/agents/antigravity_acp_test.go index 13eb8682f8..eb54758681 100644 --- a/apps/backend/internal/agent/agents/antigravity_acp_test.go +++ b/apps/backend/internal/agent/agents/antigravity_acp_test.go @@ -234,7 +234,7 @@ func TestAntigravityACP_DisplayOrderUnique(t *testing.T) { NewIFlowACP(), NewDroidACP(), NewKilocodeACP(), NewPiACP(), NewCursorACP(), NewKimiACP(), NewKiroACP(), NewQoderACP(), NewTraeACP(), NewOmpACP(), NewDevinACP(), NewGrokACP(), - NewHermesACP(), NewAntigravityACP(), NewMockAgent(), + NewHermesACP(), NewAntigravityACP(), NewMuseACP(), NewMockAgent(), } seen := map[int]string{} for _, ag := range all { diff --git a/apps/backend/internal/agent/agents/logos/muse_acp_dark.svg b/apps/backend/internal/agent/agents/logos/muse_acp_dark.svg new file mode 100644 index 0000000000..e074dd0f01 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/muse_acp_dark.svg @@ -0,0 +1 @@ +Muse diff --git a/apps/backend/internal/agent/agents/logos/muse_acp_light.svg b/apps/backend/internal/agent/agents/logos/muse_acp_light.svg new file mode 100644 index 0000000000..410d064151 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/muse_acp_light.svg @@ -0,0 +1 @@ +Muse diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index 536419de9d..60b8c1ba02 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -22,6 +22,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) { {"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}}, {"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}}, {"pi", NewPiACP(), "pi-acp", nil}, + {"muse", NewMuseACP(), "@bex-co/muse-code-acp", nil}, } for _, tt := range tests { @@ -153,6 +154,7 @@ func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) { {"copilot", NewCopilotACP()}, {"gemini", NewGemini()}, {"pi", NewPiACP()}, + {"muse", NewMuseACP()}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json index ec737fd247..114341c6b0 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json @@ -1,6 +1,7 @@ { "@agentclientprotocol/claude-agent-acp": "0.76.0", "@agentclientprotocol/codex-acp": "1.11.0", + "@bex-co/muse-code-acp": "0.6.1", "@github/copilot": "1.0.83", "@google/gemini-cli": "0.59.0", "opencode-ai": "1.18.30", diff --git a/apps/backend/internal/agent/agents/muse_acp.go b/apps/backend/internal/agent/agents/muse_acp.go new file mode 100644 index 0000000000..54444ea7c6 --- /dev/null +++ b/apps/backend/internal/agent/agents/muse_acp.go @@ -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() } diff --git a/apps/backend/internal/agent/agents/muse_acp_test.go b/apps/backend/internal/agent/agents/muse_acp_test.go new file mode 100644 index 0000000000..7aa6f37d5f --- /dev/null +++ b/apps/backend/internal/agent/agents/muse_acp_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/agents/new_acp_agents_test.go b/apps/backend/internal/agent/agents/new_acp_agents_test.go index 0701d9bd2a..0c982e187c 100644 --- a/apps/backend/internal/agent/agents/new_acp_agents_test.go +++ b/apps/backend/internal/agent/agents/new_acp_agents_test.go @@ -26,13 +26,14 @@ type acpAgentSpec struct { // to skip the test whenever ANY of these is on PATH — otherwise an // agent with multiple WithCommand fallbacks flakes when a 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" - installScript string // expected InstallScript() value (empty = unchecked) - stripEnv []string // expected Runtime().StripEnv (nil = unchecked) + 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" + installScript string // expected InstallScript() value (empty = 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 // agent because it is derived from what the CLI actually writes (see the // 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, 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) { @@ -274,7 +285,7 @@ func TestNewACPAgents_SessionDirTemplate(t *testing.T) { if got := rt.SessionConfig.SessionDirTemplate; 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", tc.spec.sessionDirTemplate) } @@ -303,6 +314,9 @@ func TestNewACPAgents_InstallScript(t *testing.T) { // 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. + if tc.spec.skipInstallBinaryCheck { + return + } primary := tc.spec.detectBinaries[0] argv := ag.BuildCommand(CommandOptions{}).Args() if !slices.Contains(argv, primary) && !strings.Contains(got, primary) { diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 302b7edcee..7be3ebef51 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -87,6 +87,7 @@ func (r *Registry) LoadDefaults() { agents.NewGrokACP(), agents.NewHermesACP(), agents.NewGooseACP(), + agents.NewMuseACP(), agents.NewAntigravityACP(), agents.NewMockAgent(), } diff --git a/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go b/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go index bf0d4238a4..c9771f904f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go @@ -416,6 +416,7 @@ func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing. {"gemini", agents.NewGemini()}, {"auggie", agents.NewAuggie()}, {"grok-acp", agents.NewGrokACP()}, + {"muse-acp", agents.NewMuseACP()}, } const kandevHome = "/tmp/kandev-test-home" 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) { cm := newCMTest(t) cm.kandevHomeDir = "/tmp/kandev-test-home" diff --git a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts index 7e9d658f44..2ff8b21ce0 100644 --- a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts @@ -15,6 +15,17 @@ import { 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 } { const tail = `${prefix}-TAIL-MARKER`; const logLine = (index: number) => diff --git a/apps/web/lib/state/slices/session-runtime/session-commits-refetch.test.ts b/apps/web/lib/state/slices/session-runtime/session-commits-refetch.test.ts index 0a87e9f774..8d89f0498d 100644 --- a/apps/web/lib/state/slices/session-runtime/session-commits-refetch.test.ts +++ b/apps/web/lib/state/slices/session-runtime/session-commits-refetch.test.ts @@ -116,3 +116,33 @@ describe("setSessionCommits — empty-response guard", () => { expect(after[0].commit_sha).toBe("new"); }); }); + +describe("addSessionCommit — duplicate notifications", () => { + let useStore: ReturnType; + + 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); + }); +}); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e3b197d54c..d464e3a85d 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -227,6 +227,19 @@ function buildSessionCommitActions(set: ImmerSet) { set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; 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 if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) { existing[0] = commit; diff --git a/docs/plans/muse-agent/plan.md b/docs/plans/muse-agent/plan.md new file mode 100644 index 0000000000..a4d3c5ef61 --- /dev/null +++ b/docs/plans/muse-agent/plan.md @@ -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@`, 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. diff --git a/docs/plans/muse-agent/task-01-add-muse-acp-agent.md b/docs/plans/muse-agent/task-01-add-muse-acp-agent.md new file mode 100644 index 0000000000..ffff6efee7 --- /dev/null +++ b/docs/plans/muse-agent/task-01-add-muse-acp-agent.md @@ -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. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 1132b7007b..9e676f481c 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -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) -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. @@ -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. +### 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@`, 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 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 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 version, Kandev uses that exact default for probes, sessions, standalone diff --git a/docs/specs/agents/requirements/muse-acp-agent.md b/docs/specs/agents/requirements/muse-acp-agent.md new file mode 100644 index 0000000000..0cd9dd8b84 --- /dev/null +++ b/docs/specs/agents/requirements/muse-acp-agent.md @@ -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@` + 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 `. +- **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. diff --git a/docs/specs/agents/system-design/muse-acp-agent.md b/docs/specs/agents/system-design/muse-acp-agent.md new file mode 100644 index 0000000000..b28e60253b --- /dev/null +++ b/docs/specs/agents/system-design/muse-acp-agent.md @@ -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@` | 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`.