mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
257 lines
9.2 KiB
TypeScript
257 lines
9.2 KiB
TypeScript
import { execAgentFile } from './invocation.js';
|
|
import { AGENT_DEFS } from './registry.js';
|
|
import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js';
|
|
import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js';
|
|
import { spawnEnvForAgent } from './env.js';
|
|
import { probeAgentAuthStatus } from './auth.js';
|
|
import { agentCapabilities } from './capabilities.js';
|
|
import { installMetaForAgent } from './metadata.js';
|
|
import type {
|
|
DetectedAgent,
|
|
RuntimeAgentDef,
|
|
RuntimeCapabilityMap,
|
|
RuntimeModelSource,
|
|
RuntimeModelOption,
|
|
} from './types.js';
|
|
|
|
type FetchedRuntimeModels = {
|
|
models: RuntimeModelOption[];
|
|
source: RuntimeModelSource;
|
|
};
|
|
|
|
async function fetchModels(
|
|
def: RuntimeAgentDef,
|
|
resolvedBin: string,
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<FetchedRuntimeModels> {
|
|
if (typeof def.fetchModels === 'function') {
|
|
try {
|
|
const parsed = await def.fetchModels(resolvedBin, env);
|
|
if (!parsed || parsed.length === 0) {
|
|
return { models: def.fallbackModels, source: 'fallback' };
|
|
}
|
|
return { models: parsed, source: 'live' };
|
|
} catch {
|
|
return { models: def.fallbackModels, source: 'fallback' };
|
|
}
|
|
}
|
|
if (!def.listModels) {
|
|
return { models: def.fallbackModels, source: 'fallback' };
|
|
}
|
|
try {
|
|
const { stdout } = await execAgentFile(resolvedBin, def.listModels.args, {
|
|
env,
|
|
timeout: def.listModels.timeoutMs ?? 5000,
|
|
// Models lists from popular CLIs (e.g. opencode) easily exceed the
|
|
// default 1MB buffer once you include every openrouter model. Bump
|
|
// it so we don't truncate the listing.
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
});
|
|
const parsed = def.listModels.parse(String(stdout));
|
|
// Empty / null parse result means the CLI didn't actually return a
|
|
// usable list (e.g. cursor-agent's "No models available"); fall back
|
|
// to the static hint so the picker isn't stuck on Default-only.
|
|
if (!parsed || parsed.length === 0) {
|
|
return { models: def.fallbackModels, source: 'fallback' };
|
|
}
|
|
return { models: parsed, source: 'live' };
|
|
} catch {
|
|
return { models: def.fallbackModels, source: 'fallback' };
|
|
}
|
|
}
|
|
|
|
type VersionProbeOutcome =
|
|
| { kind: 'not-invocable' }
|
|
| { kind: 'spawned'; version: string | null };
|
|
|
|
/**
|
|
* Run the agent's `--version` probe and classify the result. The probe
|
|
* has two distinct failure modes the catch arm has to discriminate:
|
|
*
|
|
* - **Not invocable.** The OS rejected the spawn outright (ENOENT
|
|
* for a vanished target, EACCES for a stripped-x bit, ENOTDIR
|
|
* for a broken parent), OR the wrapper script spawned but its
|
|
* underlying interpreter / target is missing and the shim exits
|
|
* with code 127 ("command not found") / 126 ("not executable").
|
|
* 127 is the canonical POSIX shell signal for "I ran but the
|
|
* thing I delegate to is gone"; 126 is the perm/not-a-binary
|
|
* sibling. Both shapes are reproducible by leftover npm bin
|
|
* shims, mise/nvm/fnm pointer files, and Windows `.CMD` shims
|
|
* whose target was uninstalled. We mark the agent unavailable
|
|
* so Settings does not advertise a ghost entry (issue #658,
|
|
* lefarcen review P2 on PR #1301).
|
|
*
|
|
* - **Spawned but `--version` was unhappy.** The binary itself ran
|
|
* (any other rejection: timeout, generic non-zero exit, stderr
|
|
* noise) so the CLI is invocable; we just can't read a version
|
|
* string. Adapters whose `--version` flag is unsupported land
|
|
* here and must keep working with `version: null`.
|
|
*
|
|
* `child_process.execFile` reports OS-level rejections with a string
|
|
* `err.code` (`'ENOENT'`, `'EACCES'`, `'ENOTDIR'`) and non-zero exit
|
|
* codes with a *numeric* `err.code` equal to the exit status, so the
|
|
* two arms below are unambiguous.
|
|
*/
|
|
async function probeVersionAtPath(
|
|
def: RuntimeAgentDef,
|
|
resolved: string,
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<VersionProbeOutcome> {
|
|
try {
|
|
const { stdout } = await execAgentFile(resolved, def.versionArgs, {
|
|
env,
|
|
timeout: def.versionProbeTimeoutMs ?? 3000,
|
|
});
|
|
const version = String(stdout).trim().split('\n')[0] ?? null;
|
|
return { kind: 'spawned', version };
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException)?.code;
|
|
if (typeof code === 'string') {
|
|
if (code === 'ENOENT' || code === 'EACCES' || code === 'ENOTDIR') {
|
|
return { kind: 'not-invocable' };
|
|
}
|
|
} else if (typeof code === 'number' && (code === 126 || code === 127)) {
|
|
return { kind: 'not-invocable' };
|
|
}
|
|
return { kind: 'spawned', version: null };
|
|
}
|
|
}
|
|
|
|
function unavailableAgent(def: RuntimeAgentDef): DetectedAgent {
|
|
return {
|
|
...stripFns(def),
|
|
models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION],
|
|
modelsSource: 'fallback',
|
|
available: false,
|
|
...installMetaForAgent(def.id),
|
|
};
|
|
}
|
|
|
|
async function probe(
|
|
def: RuntimeAgentDef,
|
|
configuredEnv: Record<string, string> = {},
|
|
): Promise<DetectedAgent> {
|
|
// Detection must probe the exact path the runtime will spawn, not just the
|
|
// PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise:
|
|
// the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper
|
|
// that is not invocable from a GUI-launched app's stripped PATH, while the
|
|
// launch resolver can still upgrade it to the packaged native Codex binary.
|
|
// If detection probes the shim but chat/run spawns the native binary, the
|
|
// UI incorrectly reports "not installed" until the user pins CODEX_BIN by
|
|
// hand even though the real launch path is healthy.
|
|
const launch = resolveAgentLaunch(def, configuredEnv);
|
|
if (!launch.selectedPath || !launch.launchPath) {
|
|
return unavailableAgent(def);
|
|
}
|
|
const probeEnv = applyAgentLaunchEnv(
|
|
spawnEnvForAgent(
|
|
def.id,
|
|
{
|
|
...process.env,
|
|
...(def.env || {}),
|
|
},
|
|
configuredEnv,
|
|
undefined,
|
|
{ resolvedBin: launch.selectedPath },
|
|
),
|
|
launch,
|
|
);
|
|
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
|
|
if (outcome.kind === 'not-invocable') {
|
|
return unavailableAgent(def);
|
|
}
|
|
// Probe `--help` once per agent and record which flags the installed CLI
|
|
// advertises. Cached on `agentCapabilities` for buildArgs to consult.
|
|
if (def.helpArgs && def.capabilityFlags) {
|
|
const caps: RuntimeCapabilityMap = {};
|
|
try {
|
|
const { stdout } = await execAgentFile(launch.launchPath, def.helpArgs, {
|
|
env: probeEnv,
|
|
timeout: 5000,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
for (const [flag, key] of Object.entries(def.capabilityFlags)) {
|
|
caps[key] = String(stdout).includes(flag);
|
|
}
|
|
} catch {
|
|
// If --help fails, leave caps empty so buildArgs falls back to the safe
|
|
// baseline (no optional flags).
|
|
}
|
|
agentCapabilities.set(def.id, caps);
|
|
}
|
|
const modelResult = await fetchModels(def, launch.launchPath, probeEnv);
|
|
const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv);
|
|
return {
|
|
...stripFns(def),
|
|
models: modelResult.models,
|
|
modelsSource: modelResult.source,
|
|
available: true,
|
|
path: launch.selectedPath,
|
|
version: outcome.version,
|
|
...(auth
|
|
? {
|
|
authStatus: auth.status,
|
|
...(auth.message ? { authMessage: auth.message } : {}),
|
|
}
|
|
: {}),
|
|
...installMetaForAgent(def.id),
|
|
};
|
|
}
|
|
|
|
function stripFns(
|
|
def: RuntimeAgentDef,
|
|
): Omit<DetectedAgent, 'models' | 'modelsSource' | 'available' | 'path' | 'version'> {
|
|
// Drop the buildArgs / listModels closures but keep declarative metadata
|
|
// (reasoningOptions, streamFormat, name, bin, etc.). `models` is
|
|
// populated separately by `fetchModels`, so we strip the static
|
|
// `fallbackModels` slot here too. `helpArgs` / `capabilityFlags` /
|
|
// `fallbackBins` / `maxPromptArgBytes` / `env` are probe-or-spawn-only
|
|
// metadata and shouldn't bleed into the API response either.
|
|
const {
|
|
buildArgs,
|
|
listModels,
|
|
fetchModels,
|
|
fallbackModels,
|
|
helpArgs,
|
|
capabilityFlags,
|
|
fallbackBins,
|
|
versionProbeTimeoutMs,
|
|
maxPromptArgBytes,
|
|
env,
|
|
...rest
|
|
} = def;
|
|
return rest;
|
|
}
|
|
|
|
async function safeProbe(
|
|
def: RuntimeAgentDef,
|
|
configuredEnv: Record<string, string> = {},
|
|
): Promise<DetectedAgent> {
|
|
try {
|
|
return await probe(def, configuredEnv);
|
|
} catch {
|
|
// Fault isolation (issue #2297): one adapter's probe blowing up
|
|
// — e.g. a synchronous filesystem throw during PATH walking on a
|
|
// packaged Windows daemon, or an async rejection from one of the
|
|
// post-launch probes — must not collapse the whole agent picker.
|
|
// Without this guard the bare `Promise.all` rejected and the
|
|
// `/api/agents` catch arm returned `[]`, so the UI silently lost
|
|
// every CLI option and fell back to BYOK / Cloud only.
|
|
return unavailableAgent(def);
|
|
}
|
|
}
|
|
|
|
export async function detectAgents(
|
|
configuredEnvByAgent: Record<string, Record<string, string>> = {},
|
|
) {
|
|
const results = await Promise.all(
|
|
AGENT_DEFS.map((def) => safeProbe(def, configuredEnvByAgent?.[def.id] ?? {})),
|
|
);
|
|
// Refresh the validation cache from whatever we just surfaced to the UI
|
|
// so /api/chat can accept any model the user could have just picked,
|
|
// including ones that only showed up after a CLI re-auth.
|
|
for (const agent of results) {
|
|
rememberLiveModels(agent.id, agent.models);
|
|
}
|
|
return results;
|
|
}
|