返回 html-video
detect.ts
根目录 / packages / runtime / src / detect.ts
1 import { execFile } from 'node:child_process';
2 import { accessSync, constants } from 'node:fs';
3 import { promisify } from 'node:util';
4 import { AGENT_DEFS } from './registry.js';
5 import type { AgentDef, DetectedAgent } from './types.js';
6
7 const exec = promisify(execFile);
8
9 // Windows has no `which`; it ships `where.exe`. POSIX shells have `which`.
10 // `where` can emit multiple lines (one per PATHEXT match) — take the first.
11 const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
12
13 async function which(bin: string): Promise<string | null> {
14 try {
15 // 8s, not 2s: detectAll() probes ~13 agents with Promise.all, so a dozen
16 // `where`/`which` processes spawn at once. Under that contention a single
17 // lookup can take several seconds on Windows; a tight 2s timeout would
18 // spuriously mark an installed agent (claude/codex) unavailable, which then
19 // makes the studio fall back to the API-key-only anthropic-api agent.
20 const { stdout } = await exec(WHICH_CMD, [bin], { timeout: 8000 });
21 const first = stdout.trim().split(/\r?\n/)[0]?.trim();
22 return first || null;
23 } catch {
24 return null;
25 }
26 }
27
28 /** PATH → static binFallbacks → async resolveBinFallback (e.g. bundled npm pkg). */
29 export async function resolveBin(def: AgentDef): Promise<string | null> {
30 const onPath = await which(def.bin);
31 if (onPath) return onPath;
32 for (const candidate of def.binFallbacks ?? []) {
33 try {
34 accessSync(candidate, constants.X_OK);
35 return candidate;
36 } catch {
37 /* not there / not executable — try next */
38 }
39 }
40 if (def.resolveBinFallback) {
41 try {
42 const resolved = await def.resolveBinFallback();
43 if (resolved) {
44 accessSync(resolved, constants.X_OK);
45 return resolved;
46 }
47 } catch {
48 /* resolver threw or path not runnable — treat as not found */
49 }
50 }
51 return null;
52 }
53
54 async function probeVersion(bin: string, args: string[]): Promise<string | null> {
55 try {
56 const { stdout } = await exec(bin, args, { timeout: 5000 });
57 return stdout.trim().split('\n')[0] ?? null;
58 } catch {
59 return null;
60 }
61 }
62
63 export async function detectOne(def: AgentDef): Promise<DetectedAgent> {
64 // ---- HTTP agents (anthropic-api etc) ----
65 if (def.kind === 'http') {
66 const probe = def.httpProbe ? await def.httpProbe() : { available: false };
67 return {
68 id: def.id,
69 name: def.name,
70 bin: def.bin,
71 available: probe.available,
72 ...(probe.version !== undefined && { version: probe.version }),
73 ...(def.installUrl !== undefined && { installUrl: def.installUrl }),
74 };
75 }
76 const path = await resolveBin(def);
77 if (!path) {
78 return {
79 id: def.id,
80 name: def.name,
81 bin: def.bin,
82 available: false,
83 ...(def.installUrl !== undefined && { installUrl: def.installUrl }),
84 };
85 }
86 let version = await probeVersion(path, def.versionArgs);
87 // Found on disk — but some agents need a further gate (e.g. AMR login state).
88 if (def.extraDetect) {
89 const extra = await def.extraDetect(path);
90 if (extra.version !== undefined && extra.version !== null) version = extra.version;
91 return {
92 id: def.id,
93 name: def.name,
94 bin: def.bin,
95 available: extra.available,
96 path,
97 version,
98 ...(extra.hint !== undefined && { hint: extra.hint }),
99 ...(def.installUrl !== undefined && { installUrl: def.installUrl }),
100 };
101 }
102 return {
103 id: def.id,
104 name: def.name,
105 bin: def.bin,
106 available: true,
107 path,
108 version,
109 ...(def.installUrl !== undefined && { installUrl: def.installUrl }),
110 };
111 }
112
113 // In-process cache. Agent install state doesn't change inside one server
114 // run, so spawning `which` + `<bin> --version` on every /api/agents request
115 // (~400ms total for two agents) is wasted latency that blocks the studio
116 // composer on first paint. TTL guards against the rare "user installed mid-
117 // session" case.
118 const DETECT_TTL_MS = 5 * 60 * 1000;
119 let detectCache: { ts: number; result: DetectedAgent[] } | null = null;
120
121 export async function detectAll(opts?: { force?: boolean }): Promise<DetectedAgent[]> {
122 const now = Date.now();
123 if (!opts?.force && detectCache && now - detectCache.ts < DETECT_TTL_MS) {
124 return detectCache.result;
125 }
126 const result = await Promise.all(AGENT_DEFS.map(detectOne));
127 detectCache = { ts: now, result };
128 return result;
129 }
130
130 lines TYPESCRIPT