mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
Qoder is a fully supported provider, but a GUI-launched daemon could never detect it. Two gaps, both in agent discovery: - probeAgentCLIs called resolveAgentExecutablePath directly for qoder instead of going through the shared probe() helper, so qoder was the only provider with no login-shell fallback. A daemon started from Finder/Launchpad (the apple.dmg desktop build) does not inherit the interactive shell PATH, so a qodercli in an npm global prefix or any ~/.zshrc-added dir stayed invisible no matter how often the daemon restarted. - "qodercli" was missing from defaultAgentCommandNames, which is the only list cachedShellResolvedAgents asks the login shell about. Even with the fallback wired up, the resolver would not have looked for it. TestDefaultAgentCommandNamesCoversAllProbes was supposed to catch exactly this, but it parsed config.go for probe() calls and silently became a no-op when probeAgentCLIs moved to agents_probe.go. It now parses agents_probe.go and asserts it found at least one probe() per default command, so a future move fails loudly instead of passing vacuously. Pinned-path semantics are unchanged: an absolute/relative MULTICA_QODER_PATH that does not exist stays a hard miss rather than silently resolving a different binary. Co-authored-by: multica-agent <github@multica.ai>
222 lines
9.2 KiB
Go
222 lines
9.2 KiB
Go
package daemon
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// shellResolveTTL bounds how long one login-shell PATH resolution is reused
|
|
// across probeAgentCLIs calls.
|
|
//
|
|
// This is deliberately much longer than agentDiscoveryInterval so the frequent
|
|
// discovery round stays a pure exec.LookPath sweep: resolveAgentsViaLoginShell
|
|
// forks the user's login shell and runs their rc files, and there is almost
|
|
// always at least one uninstalled provider to miss LookPath on, so a short TTL
|
|
// would turn discovery into a shell fork every few minutes for the life of the
|
|
// daemon.
|
|
//
|
|
// The practical effect: a CLI on the daemon's own PATH is discovered within
|
|
// agentDiscoveryInterval, while one reachable only through the login shell
|
|
// (nvm/fnm shims, a ~/.local/bin that only ~/.zshrc adds) takes up to this long
|
|
// — still without a restart, which is the part that was previously impossible.
|
|
var shellResolveTTL = 30 * time.Minute
|
|
|
|
var (
|
|
shellResolveMu sync.Mutex
|
|
shellResolveCache map[string]string
|
|
shellResolveKey string
|
|
shellResolvedAt time.Time
|
|
)
|
|
|
|
// shellResolveEnvKey fingerprints the environment that determines what a login
|
|
// shell resolves. A change to any of these invalidates the cache immediately,
|
|
// independent of the TTL — the cached answer was for a different environment.
|
|
func shellResolveEnvKey() string {
|
|
return strings.Join([]string{
|
|
os.Getenv("PATH"),
|
|
os.Getenv("SHELL"),
|
|
os.Getenv("HOME"),
|
|
}, "\x00")
|
|
}
|
|
|
|
// cachedShellResolvedAgents resolves every standard agent command name through
|
|
// the user's login shell, reusing the previous result for shellResolveTTL as
|
|
// long as the resolution-relevant environment is unchanged.
|
|
//
|
|
// resolveAgentsViaLoginShell forks the user's login shell, which runs their rc
|
|
// files, so this must stay a cache and not a per-probe call: probeAgentCLIs now
|
|
// runs periodically on a live daemon, and there is almost always at least one
|
|
// uninstalled provider to miss LookPath on.
|
|
func cachedShellResolvedAgents() map[string]string {
|
|
shellResolveMu.Lock()
|
|
defer shellResolveMu.Unlock()
|
|
key := shellResolveEnvKey()
|
|
if shellResolveCache != nil && shellResolveKey == key && time.Since(shellResolvedAt) < shellResolveTTL {
|
|
return shellResolveCache
|
|
}
|
|
resolved := resolveAgentsViaLoginShell(defaultAgentCommandNames)
|
|
if resolved == nil {
|
|
// Distinguish "resolved nothing" from "never resolved" so a failing
|
|
// shell doesn't get re-forked on every probe inside the TTL window.
|
|
resolved = map[string]string{}
|
|
}
|
|
shellResolveCache = resolved
|
|
shellResolveKey = key
|
|
shellResolvedAt = time.Now()
|
|
return shellResolveCache
|
|
}
|
|
|
|
// probeAgentCLIs discovers which built-in agent CLIs are installed on this
|
|
// machine and returns one AgentEntry per provider that resolved.
|
|
//
|
|
// This is pure discovery: no version detection and no minimum-version gate
|
|
// (detectBuiltinRuntimes owns those, per registration round). The result is
|
|
// therefore the machine's *availability* set, which is exactly what
|
|
// /health.agents reports and what `multica daemon probe-runtimes` prints.
|
|
//
|
|
// It is called once from LoadConfig at startup and again from the periodic
|
|
// workspace sync (refreshAgentAvailability), so a CLI the user installs while
|
|
// the daemon is already running gets picked up without a restart (MUL-5439).
|
|
// Everything it reads is process-external (PATH, MULTICA_*_PATH, MULTICA_*_MODEL),
|
|
// so re-running it is the only way to observe such an install.
|
|
//
|
|
// A var so tests can stub discovery without installing real CLIs.
|
|
var probeAgentCLIs = func() map[string]AgentEntry {
|
|
// 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.
|
|
//
|
|
// The resolution is cached process-wide with a TTL (not per call) because
|
|
// this function now also runs periodically on a live daemon: a per-call
|
|
// sync.Once would fork a login shell on every discovery round, since there
|
|
// is almost always at least one uninstalled provider to miss on. The TTL
|
|
// still lets a CLI installed into a login-shell-only PATH dir (nvm, fnm,
|
|
// ~/.local/bin via ~/.zshrc) be discovered without a restart (MUL-5439).
|
|
getShellResolved := cachedShellResolvedAgents
|
|
probe := func(envVar, defaultCmd, modelEnv string) (AgentEntry, bool) {
|
|
cmd := envOrDefault(envVar, defaultCmd)
|
|
if path, err := resolveAgentExecutablePath(cmd); err == nil {
|
|
return AgentEntry{
|
|
Path: path,
|
|
Command: 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,
|
|
Command: cmd,
|
|
Model: strings.TrimSpace(os.Getenv(modelEnv)),
|
|
}, true
|
|
}
|
|
if defaultCmd == "codex" && cmd == defaultCmd {
|
|
// Codex Desktop bundles its CLI inside the macOS app instead of
|
|
// installing it onto PATH.
|
|
for _, p := range codexDesktopAppBundlePaths() {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return AgentEntry{
|
|
Path: p,
|
|
Command: cmd,
|
|
Model: strings.TrimSpace(os.Getenv(modelEnv)),
|
|
}, true
|
|
}
|
|
}
|
|
}
|
|
return AgentEntry{}, false
|
|
}
|
|
|
|
agents := map[string]AgentEntry{}
|
|
if e, ok := probe("MULTICA_CLAUDE_PATH", "claude", "MULTICA_CLAUDE_MODEL"); ok {
|
|
agents["claude"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_CODEX_PATH", "codex", "MULTICA_CODEX_MODEL"); ok {
|
|
agents["codex"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_OPENCODE_PATH", "opencode", "MULTICA_OPENCODE_MODEL"); ok {
|
|
agents["opencode"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_DEVECO_PATH", "deveco", "MULTICA_DEVECO_MODEL"); ok {
|
|
agents["deveco"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_OPENCLAW_PATH", "openclaw", "MULTICA_OPENCLAW_MODEL"); ok {
|
|
agents["openclaw"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_HERMES_PATH", "hermes", "MULTICA_HERMES_MODEL"); ok {
|
|
agents["hermes"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_PI_PATH", "pi", "MULTICA_PI_MODEL"); ok {
|
|
agents["pi"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_CURSOR_PATH", "cursor-agent", "MULTICA_CURSOR_MODEL"); ok {
|
|
agents["cursor"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_COPILOT_PATH", "copilot", "MULTICA_COPILOT_MODEL"); ok {
|
|
agents["copilot"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_KIMI_PATH", "kimi", "MULTICA_KIMI_MODEL"); ok {
|
|
agents["kimi"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_KIRO_PATH", "kiro-cli", "MULTICA_KIRO_MODEL"); ok {
|
|
agents["kiro"] = e
|
|
}
|
|
if e, ok := probe("MULTICA_CODEBUDDY_PATH", "codebuddy", "MULTICA_CODEBUDDY_MODEL"); ok {
|
|
agents["codebuddy"] = e
|
|
}
|
|
// agy 1.0.6 added a `--model` flag (MUL-3125), so Antigravity now takes a
|
|
// model env like every other backend. MULTICA_ANTIGRAVITY_MODEL seeds the
|
|
// daemon-wide default; its value is the exact `agy models` display string
|
|
// (e.g. "Claude Opus 4.6 (Thinking)"), not a provider/model slug.
|
|
if e, ok := probe("MULTICA_ANTIGRAVITY_PATH", "agy", "MULTICA_ANTIGRAVITY_MODEL"); ok {
|
|
agents["antigravity"] = e
|
|
}
|
|
// Qoder CLI ships as the `qodercli` binary (Qoder Desktop does not put it
|
|
// on PATH; users install it separately, often via an npm global prefix).
|
|
// It must go through probe() like every other provider so the login-shell
|
|
// fallback applies: a GUI/Launchpad-started daemon does not inherit the
|
|
// interactive shell PATH, and without the fallback a perfectly good
|
|
// qodercli install stayed invisible across restarts (MUL-5524).
|
|
if e, ok := probe("MULTICA_QODER_PATH", "qodercli", "MULTICA_QODER_MODEL"); ok {
|
|
agents["qoder"] = e
|
|
}
|
|
// ByteDance official TRAE CLI (the `traecli` binary from https://docs.trae.cn/cli),
|
|
// driven over ACP via `traecli acp serve --yolo`. MULTICA_TRAECLI_MODEL seeds
|
|
// the daemon-wide default model (a model id from the user's logged-in traecli
|
|
// catalog).
|
|
if e, ok := probe("MULTICA_TRAECLI_PATH", "traecli", "MULTICA_TRAECLI_MODEL"); ok {
|
|
agents["traecli"] = e
|
|
}
|
|
// xAI Grok Build CLI (`grok`), driven over ACP via
|
|
// `grok agent --always-approve stdio`. MULTICA_GROK_MODEL seeds the
|
|
// daemon-wide default (e.g. grok-4.5).
|
|
if e, ok := probe("MULTICA_GROK_PATH", "grok", "MULTICA_GROK_MODEL"); ok {
|
|
agents["grok"] = e
|
|
}
|
|
// Qwen Code (`qwen`) runs headlessly with -p and stream-json. Its native
|
|
// QWEN.md and .qwen/skills task context is prepared by execenv.
|
|
if e, ok := probe("MULTICA_QWEN_PATH", "qwen", "MULTICA_QWEN_MODEL"); ok {
|
|
agents["qwen"] = e
|
|
}
|
|
return agents
|
|
}
|