@@ -3,13 +3,20 @@ use std::path::Path;
33use std:: path:: PathBuf ;
44use std:: process:: Stdio ;
55use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
6- use std:: sync:: LazyLock ;
6+ #[ cfg( not( windows) ) ]
7+ use std:: sync:: Arc ;
8+ #[ cfg( not( windows) ) ]
9+ use std:: sync:: Mutex ;
710use std:: time:: Duration ;
811
912use rmcp:: model:: { CallToolResult , Content } ;
1013use schemars:: JsonSchema ;
1114use serde:: { Deserialize , Serialize } ;
1215use tokio:: io:: { AsyncBufReadExt , BufReader } ;
16+ #[ cfg( not( windows) ) ]
17+ use tokio:: sync:: OnceCell ;
18+ #[ cfg( not( windows) ) ]
19+ use tokio:: task:: JoinHandle ;
1320use tokio_stream:: { wrappers:: SplitStream , StreamExt } ;
1421
1522use crate :: subprocess:: SubprocessExt ;
@@ -197,14 +204,61 @@ fn resolve_login_shell_path() -> Option<String> {
197204 }
198205}
199206
207+ /// Resolves the user's login-shell PATH in the background.
208+ ///
209+ /// Spawned at `ShellTool` construction so the ~hundreds-of-ms cost of sourcing
210+ /// the user's shell profile overlaps with the rest of agent setup and the
211+ /// first LLM turn. The first `shell` invocation awaits the result; subsequent
212+ /// invocations read from the cached cell.
200213#[ cfg( not( windows) ) ]
201- static LOGIN_PATH : LazyLock < Option < String > > = LazyLock :: new ( resolve_login_shell_path) ;
214+ struct LoginPath {
215+ cell : OnceCell < Option < Arc < str > > > ,
216+ handle : Mutex < Option < JoinHandle < Option < String > > > > ,
217+ }
218+
219+ #[ cfg( not( windows) ) ]
220+ impl LoginPath {
221+ fn spawn ( ) -> Self {
222+ let handle = tokio:: task:: spawn_blocking ( resolve_login_shell_path) ;
223+ Self {
224+ cell : OnceCell :: new ( ) ,
225+ handle : Mutex :: new ( Some ( handle) ) ,
226+ }
227+ }
228+
229+ #[ cfg( test) ]
230+ fn resolved ( value : Option < String > ) -> Self {
231+ let cell = OnceCell :: new ( ) ;
232+ let _ = cell. set ( value. map ( Arc :: from) ) ;
233+ Self {
234+ cell,
235+ handle : Mutex :: new ( None ) ,
236+ }
237+ }
238+
239+ async fn get ( & self ) -> Option < Arc < str > > {
240+ self . cell
241+ . get_or_init ( || async {
242+ let handle = self
243+ . handle
244+ . lock ( )
245+ . expect ( "login_path mutex poisoned" )
246+ . take ( ) ;
247+ match handle {
248+ Some ( h) => h. await . ok ( ) . flatten ( ) . map ( Arc :: from) ,
249+ None => None ,
250+ }
251+ } )
252+ . await
253+ . clone ( )
254+ }
255+ }
202256
203257pub struct ShellTool {
204258 output_dir : tempfile:: TempDir ,
205259 call_index : AtomicUsize ,
206260 #[ cfg( not( windows) ) ]
207- login_path : Option < String > ,
261+ login_path : LoginPath ,
208262}
209263
210264impl ShellTool {
@@ -213,7 +267,7 @@ impl ShellTool {
213267 output_dir : tempfile:: tempdir ( ) ?,
214268 call_index : AtomicUsize :: new ( 0 ) ,
215269 #[ cfg( not( windows) ) ]
216- login_path : LOGIN_PATH . clone ( ) ,
270+ login_path : LoginPath :: spawn ( ) ,
217271 } )
218272 }
219273
@@ -223,7 +277,7 @@ impl ShellTool {
223277 output_dir : tempfile:: tempdir ( ) ?,
224278 call_index : AtomicUsize :: new ( 0 ) ,
225279 #[ cfg( not( windows) ) ]
226- login_path : None ,
280+ login_path : LoginPath :: resolved ( None ) ,
227281 } )
228282 }
229283
@@ -241,15 +295,17 @@ impl ShellTool {
241295 }
242296
243297 #[ cfg( not( windows) ) ]
244- let login_path = self . login_path . as_deref ( ) ;
298+ let login_path = self . login_path . get ( ) . await ;
299+ #[ cfg( not( windows) ) ]
300+ let login_path_ref = login_path. as_deref ( ) ;
245301 #[ cfg( windows) ]
246- let login_path : Option < & str > = None ;
302+ let login_path_ref : Option < & str > = None ;
247303
248304 let execution = match run_command (
249305 & params. command ,
250306 params. timeout_secs ,
251307 working_dir,
252- login_path ,
308+ login_path_ref ,
253309 )
254310 . await
255311 {
0 commit comments