diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 2f04e2e3e..76813a9fb 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -1,12 +1,14 @@ package daemon import ( + "context" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/mattn/go-shellwords" @@ -95,84 +97,88 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{}, err } - // Probe available agent CLIs + // Probe available agent CLIs. exec.LookPath is the primary path, but on + // macOS/Linux a GUI-launched daemon (Electron, Launchpad) does not + // inherit the user's interactive shell PATH — fnm/nvm/volta multishells, + // the Anthropic native installer prefix, and per-user npm prefixes all + // live in dirs that only get added to PATH by ~/.zshrc or ~/.bashrc. + // shellResolvedAgents asks the user's login shell, lazily on first miss, + // to resolve every standard agent name to its canonical absolute path, + // so we can find binaries the bare daemon process can't see. See + // resolveAgentsViaLoginShell for the details and constraints. + // + // Laziness matters: the happy path (every agent on the daemon's PATH or + // pinned to an explicit MULTICA_*_PATH) must not pay the cost of + // spawning the user's login shell — that touches their rc files and + // adds startup latency that scales with whatever they put in there. We + // only fork a shell when a bare command name actually missed LookPath. + var ( + shellResolveOnce sync.Once + shellResolved map[string]string + ) + getShellResolved := func() map[string]string { + shellResolveOnce.Do(func() { + shellResolved = resolveAgentsViaLoginShell(defaultAgentCommandNames) + }) + return shellResolved + } + probe := func(envVar, defaultCmd, modelEnv string) (AgentEntry, bool) { + cmd := envOrDefault(envVar, defaultCmd) + if _, err := exec.LookPath(cmd); err == nil { + return AgentEntry{ + Path: cmd, + Model: strings.TrimSpace(os.Getenv(modelEnv)), + }, true + } + // The shell fallback only rescues bare command names. An operator + // who pinned MULTICA_*_PATH to an absolute or relative path that + // doesn't exist should hard-miss, not silently get a different + // binary. + if strings.ContainsAny(cmd, "/\\") { + return AgentEntry{}, false + } + if path, ok := getShellResolved()[cmd]; ok { + return AgentEntry{ + Path: path, + Model: strings.TrimSpace(os.Getenv(modelEnv)), + }, true + } + return AgentEntry{}, false + } + agents := map[string]AgentEntry{} - claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude") - if _, err := exec.LookPath(claudePath); err == nil { - agents["claude"] = AgentEntry{ - Path: claudePath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")), - } + if e, ok := probe("MULTICA_CLAUDE_PATH", "claude", "MULTICA_CLAUDE_MODEL"); ok { + agents["claude"] = e } - codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex") - if _, err := exec.LookPath(codexPath); err == nil { - agents["codex"] = AgentEntry{ - Path: codexPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")), - } + if e, ok := probe("MULTICA_CODEX_PATH", "codex", "MULTICA_CODEX_MODEL"); ok { + agents["codex"] = e } - opencodePath := envOrDefault("MULTICA_OPENCODE_PATH", "opencode") - if _, err := exec.LookPath(opencodePath); err == nil { - agents["opencode"] = AgentEntry{ - Path: opencodePath, - Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")), - } + if e, ok := probe("MULTICA_OPENCODE_PATH", "opencode", "MULTICA_OPENCODE_MODEL"); ok { + agents["opencode"] = e } - openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw") - if _, err := exec.LookPath(openclawPath); err == nil { - agents["openclaw"] = AgentEntry{ - Path: openclawPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")), - } + if e, ok := probe("MULTICA_OPENCLAW_PATH", "openclaw", "MULTICA_OPENCLAW_MODEL"); ok { + agents["openclaw"] = e } - hermesPath := envOrDefault("MULTICA_HERMES_PATH", "hermes") - if _, err := exec.LookPath(hermesPath); err == nil { - agents["hermes"] = AgentEntry{ - Path: hermesPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_HERMES_MODEL")), - } + if e, ok := probe("MULTICA_HERMES_PATH", "hermes", "MULTICA_HERMES_MODEL"); ok { + agents["hermes"] = e } - geminiPath := envOrDefault("MULTICA_GEMINI_PATH", "gemini") - if _, err := exec.LookPath(geminiPath); err == nil { - agents["gemini"] = AgentEntry{ - Path: geminiPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")), - } + if e, ok := probe("MULTICA_GEMINI_PATH", "gemini", "MULTICA_GEMINI_MODEL"); ok { + agents["gemini"] = e } - piPath := envOrDefault("MULTICA_PI_PATH", "pi") - if _, err := exec.LookPath(piPath); err == nil { - agents["pi"] = AgentEntry{ - Path: piPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")), - } + if e, ok := probe("MULTICA_PI_PATH", "pi", "MULTICA_PI_MODEL"); ok { + agents["pi"] = e } - cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent") - if _, err := exec.LookPath(cursorPath); err == nil { - agents["cursor"] = AgentEntry{ - Path: cursorPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")), - } + if e, ok := probe("MULTICA_CURSOR_PATH", "cursor-agent", "MULTICA_CURSOR_MODEL"); ok { + agents["cursor"] = e } - copilotPath := envOrDefault("MULTICA_COPILOT_PATH", "copilot") - if _, err := exec.LookPath(copilotPath); err == nil { - agents["copilot"] = AgentEntry{ - Path: copilotPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")), - } + if e, ok := probe("MULTICA_COPILOT_PATH", "copilot", "MULTICA_COPILOT_MODEL"); ok { + agents["copilot"] = e } - kimiPath := envOrDefault("MULTICA_KIMI_PATH", "kimi") - if _, err := exec.LookPath(kimiPath); err == nil { - agents["kimi"] = AgentEntry{ - Path: kimiPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")), - } + if e, ok := probe("MULTICA_KIMI_PATH", "kimi", "MULTICA_KIMI_MODEL"); ok { + agents["kimi"] = e } - kiroPath := envOrDefault("MULTICA_KIRO_PATH", "kiro-cli") - if _, err := exec.LookPath(kiroPath); err == nil { - agents["kiro"] = AgentEntry{ - Path: kiroPath, - Model: strings.TrimSpace(os.Getenv("MULTICA_KIRO_MODEL")), - } + if e, ok := probe("MULTICA_KIRO_PATH", "kiro-cli", "MULTICA_KIRO_MODEL"); ok { + agents["kiro"] = e } if len(agents) == 0 { return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, or kiro-cli and ensure it is on PATH") @@ -442,3 +448,201 @@ func shellArgsFromEnv(name string) ([]string, error) { } return args, nil } + +// defaultAgentCommandNames lists the command names the agent probe loop tries +// before any MULTICA_*_PATH override is applied. Kept in sync with the +// `probe(...)` calls in LoadConfig — the shell-fallback resolver uses this +// list to pre-fetch canonical paths for every known agent in a single shell +// invocation, instead of paying the cost-per-miss. +var defaultAgentCommandNames = []string{ + "claude", "codex", "opencode", "openclaw", "hermes", + "gemini", "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", +} + +// loginShellResolveTimeout caps how long the daemon will wait for the user's +// login shell to print canonical agent paths. A broken rc file should not +// block startup — if the shell takes longer than this, we proceed without +// shell-resolved fallbacks and the daemon falls back to the same behaviour +// it had before this code was added. +const loginShellResolveTimeout = 3 * time.Second + +// loginShellResolveWaitDelay is the hard cap that runs *after* +// loginShellResolveTimeout has elapsed and `CommandContext` has signalled the +// shell to exit. The context kills the shell process itself, but rc files in +// the wild routinely background things that inherit stdout (`nvm` shims, +// `direnv hook`, `eval $(starship init)`, plain `&`). Those survivors keep +// the stdout pipe open and `cmd.Output()` will block on EOF for as long as +// they live. Cmd.WaitDelay (Go 1.20+) forcibly closes the pipes and returns +// once this delay elapses, so the total daemon-startup penalty caused by a +// pathological rc file is bounded by `timeout + waitDelay`, not by however +// long the user's background processes happen to run. +const loginShellResolveWaitDelay = 2 * time.Second + +// supportedLoginShells limits which interpreters we will invoke via +// ` -ilc