@@ -79,6 +79,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7979 private readonly List < LifecycleSubscription > _lifecycleHandlers = [ ] ;
8080
8181 private Task < Connection > ? _connectionTask ;
82+ private FfiRuntimeHost ? _ffiHost ;
8283 private bool _disposed ;
8384 private int ? _actualPort ;
8485 private int ? _negotiatedProtocolVersion ;
@@ -135,13 +136,16 @@ private sealed record LifecycleSubscription(Type EventType, Action<SessionLifecy
135136 public CopilotClient ( CopilotClientOptions ? options = null )
136137 {
137138 _options = options ?? new ( ) ;
138- _connection = _options . Connection ?? RuntimeConnection . ForStdio ( ) ;
139+ _connection = _options . Connection ?? ResolveDefaultConnection ( _options ) ;
139140
140141 switch ( _connection )
141142 {
142143 case StdioRuntimeConnection :
143144 break ;
144145
146+ case InProcessRuntimeConnection :
147+ break ;
148+
145149 case TcpRuntimeConnection tcp :
146150 if ( tcp . ConnectionToken is { Length : 0 } )
147151 {
@@ -198,6 +202,38 @@ _options.SessionFs is not null ||
198202 }
199203 }
200204
205+ /// <summary>
206+ /// Environment variable that overrides the transport used when the caller does not
207+ /// specify <see cref="CopilotClientOptions.Connection"/>. Accepts <c>"inprocess"</c>
208+ /// or <c>"stdio"</c> (case-insensitive); unset preserves the default stdio transport.
209+ /// Any other value is an error. Ignored when a <see cref="RuntimeConnection"/> is set
210+ /// explicitly.
211+ /// </summary>
212+ internal const string DefaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION" ;
213+
214+ /// <summary>
215+ /// Resolves the default <see cref="RuntimeConnection"/> for the no-Connection case,
216+ /// honoring <see cref="DefaultConnectionEnvVar"/>.
217+ /// </summary>
218+ private static RuntimeConnection ResolveDefaultConnection ( CopilotClientOptions options )
219+ {
220+ var value = options . Environment is not null
221+ && options . Environment . TryGetValue ( DefaultConnectionEnvVar , out var fromOptions )
222+ ? fromOptions
223+ : Environment . GetEnvironmentVariable ( DefaultConnectionEnvVar ) ;
224+
225+ if ( string . IsNullOrEmpty ( value ) || string . Equals ( value , "stdio" , StringComparison . OrdinalIgnoreCase ) )
226+ {
227+ return RuntimeConnection . ForStdio ( ) ;
228+ }
229+ if ( string . Equals ( value , "inprocess" , StringComparison . OrdinalIgnoreCase ) )
230+ {
231+ return RuntimeConnection . ForInProcess ( ) ;
232+ }
233+ throw new ArgumentException (
234+ $ "Invalid { DefaultConnectionEnvVar } value '{ value } '. Expected 'inprocess', 'stdio', or unset.") ;
235+ }
236+
201237 /// <summary>
202238 /// Parses a runtime URL into a URI with host and port.
203239 /// </summary>
@@ -251,7 +287,16 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
251287
252288 try
253289 {
254- if ( _connection is UriRuntimeConnection )
290+ if ( _connection is InProcessRuntimeConnection )
291+ {
292+ // In-process FFI hosting: load the Rust cdylib and let it spawn
293+ // the CLI worker, instead of the SDK launching a CLI child process.
294+ var ffiHost = FfiRuntimeHost . Create ( ResolveCliPathForFfi ( ) , GetNapiPrebuildsFolderOrThrow ( ) , _options . Environment , _logger ) ;
295+ _ffiHost = ffiHost ;
296+ await ffiHost . StartAsync ( ct ) ;
297+ connection = await ConnectToServerAsync ( null , null , null , null , ct , ffiHost ) ;
298+ }
299+ else if ( _connection is UriRuntimeConnection )
255300 {
256301 // External runtime
257302 _actualPort = _optionsPort ;
@@ -438,7 +483,7 @@ private async Task CleanupConnectionAsync(List<Exception>? errors, bool graceful
438483
439484 private async Task CleanupConnectionAsync ( Connection ctx , List < Exception > ? errors , bool gracefulRuntimeShutdown )
440485 {
441- if ( gracefulRuntimeShutdown && ctx . CliProcess is not null )
486+ if ( gracefulRuntimeShutdown && ( ctx . CliProcess is not null || ctx . FfiHost is not null ) )
442487 {
443488 var runtimeShutdownTimestamp = Stopwatch . GetTimestamp ( ) ;
444489 try
@@ -478,6 +523,13 @@ or IOException
478523 {
479524 await CleanupCliProcessAsync ( childProcess , ctx . StderrPump , errors , _logger ) ;
480525 }
526+
527+ if ( ctx . FfiHost is { } ffiHost )
528+ {
529+ try { ffiHost . Dispose ( ) ; }
530+ catch ( Exception ex ) { AddCleanupError ( errors , ex , _logger ) ; }
531+ _ffiHost = null ;
532+ }
481533 }
482534
483535 private static async Task CleanupCliProcessAsync ( Process childProcess , ProcessStderrPump ? stderrPump , List < Exception > ? errors , ILogger ? logger )
@@ -1795,12 +1847,14 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
17951847 int ? serverVersion ;
17961848 try
17971849 {
1798- var token = _connection switch
1799- {
1800- TcpRuntimeConnection tcp => tcp . ConnectionToken ,
1801- UriRuntimeConnection uri => uri . ConnectionToken ,
1802- _ => null ,
1803- } ;
1850+ var token = _ffiHost is not null
1851+ ? null // FFI hosting is an ungated in-process connection; no token.
1852+ : _connection switch
1853+ {
1854+ TcpRuntimeConnection tcp => tcp . ConnectionToken ,
1855+ UriRuntimeConnection uri => uri . ConnectionToken ,
1856+ _ => null ,
1857+ } ;
18041858 var connectResponse = await InvokeRpcAsync < ConnectResult > (
18051859 connection . Rpc ,
18061860 "connect" ,
@@ -2087,6 +2141,57 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
20872141 return arch != null ? $ "{ os } -{ arch } " : null ;
20882142 }
20892143
2144+ private string ResolveCliPathForFfi ( )
2145+ {
2146+ var envCliPath = _options . Environment is not null && _options . Environment . TryGetValue ( "COPILOT_CLI_PATH" , out var envValue )
2147+ ? envValue
2148+ : System . Environment . GetEnvironmentVariable ( "COPILOT_CLI_PATH" ) ;
2149+ if ( ! string . IsNullOrEmpty ( envCliPath ) )
2150+ {
2151+ return envCliPath ;
2152+ }
2153+
2154+ // Fall back to the bundled single-file CLI the same way stdio discovers it.
2155+ // It embeds its own Node and is spawned directly as `copilot --embedded-host`,
2156+ // with the sibling cdylib loaded in-process (FfiRuntimeHost.Create prefers the
2157+ // flat `libcopilot_runtime.so`/`copilot_runtime.dll` next to the CLI, falling
2158+ // back to the dev `prebuilds/<folder>/runtime.node` layout).
2159+ var bundled = GetBundledCliPath ( out var searchedPath ) ;
2160+ return bundled
2161+ ?? throw new InvalidOperationException (
2162+ "In-process FFI hosting requires the Copilot CLI. Set the COPILOT_CLI_PATH "
2163+ + $ "environment variable, or ensure the bundled CLI is present (looked in '{ searchedPath } ').") ;
2164+ }
2165+
2166+ /// <summary>
2167+ /// Returns the napi-rs prebuilds folder name for the current host — the
2168+ /// <c><node-platform>-<arch></c> convention (e.g. <c>win32-x64</c>,
2169+ /// <c>darwin-arm64</c>, <c>linux-x64</c>) under which the runtime ships
2170+ /// <c>prebuilds/<folder>/runtime.node</c>. This differs from the .NET RID
2171+ /// (<c>win-x64</c>/<c>osx-x64</c>) for Windows and macOS.
2172+ /// </summary>
2173+ private static string ? GetNapiPrebuildsFolder ( )
2174+ {
2175+ string platform ;
2176+ if ( OperatingSystem . IsWindows ( ) ) platform = "win32" ;
2177+ else if ( OperatingSystem . IsLinux ( ) ) platform = "linux" ;
2178+ else if ( OperatingSystem . IsMacOS ( ) ) platform = "darwin" ;
2179+ else return null ;
2180+
2181+ var arch = System . Runtime . InteropServices . RuntimeInformation . OSArchitecture switch
2182+ {
2183+ System . Runtime . InteropServices . Architecture . X64 => "x64" ,
2184+ System . Runtime . InteropServices . Architecture . Arm64 => "arm64" ,
2185+ _ => null ,
2186+ } ;
2187+
2188+ return arch != null ? $ "{ platform } -{ arch } " : null ;
2189+ }
2190+
2191+ private static string GetNapiPrebuildsFolderOrThrow ( ) =>
2192+ GetNapiPrebuildsFolder ( )
2193+ ?? throw new InvalidOperationException ( "Could not determine a napi-rs prebuilds folder for FFI hosting." ) ;
2194+
20902195 private static ( string FileName , IEnumerable < string > Args ) ResolveCliCommand ( string cliPath , IEnumerable < string > args )
20912196 {
20922197 var isJsFile = cliPath . EndsWith ( ".js" , StringComparison . OrdinalIgnoreCase ) ;
@@ -2099,7 +2204,7 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
20992204 return ( cliPath , args ) ;
21002205 }
21012206
2102- private async Task < Connection > ConnectToServerAsync ( Process ? cliProcess , string ? tcpHost , int ? tcpPort , ProcessStderrPump ? stderrPump , CancellationToken cancellationToken )
2207+ private async Task < Connection > ConnectToServerAsync ( Process ? cliProcess , string ? tcpHost , int ? tcpPort , ProcessStderrPump ? stderrPump , CancellationToken cancellationToken , FfiRuntimeHost ? ffiHost = null )
21032208 {
21042209 var setupTimestamp = Stopwatch . GetTimestamp ( ) ;
21052210 NetworkStream ? networkStream = null ;
@@ -2109,7 +2214,12 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
21092214 {
21102215 Stream inputStream , outputStream ;
21112216
2112- if ( _connection is StdioRuntimeConnection )
2217+ if ( ffiHost is not null )
2218+ {
2219+ inputStream = ffiHost . ReceiveStream ;
2220+ outputStream = ffiHost . SendStream ;
2221+ }
2222+ else if ( _connection is StdioRuntimeConnection )
21132223 {
21142224 if ( cliProcess == null )
21152225 {
@@ -2175,7 +2285,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
21752285 "CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}" ,
21762286 setupTimestamp ) ;
21772287
2178- var connection = new Connection ( rpc , cliProcess , networkStream , stderrPump ) ;
2288+ var connection = new Connection ( rpc , cliProcess , networkStream , stderrPump , ffiHost ) ;
21792289 _serverRpc = connection . Server ;
21802290
21812291 return connection ;
@@ -2378,14 +2488,16 @@ private class Connection(
23782488 JsonRpc rpc ,
23792489 Process ? cliProcess , // Set if we created the child process
23802490 NetworkStream ? networkStream , // Set if using TCP
2381- ProcessStderrPump ? stderrPump = null ) // Captures stderr for error messages
2491+ ProcessStderrPump ? stderrPump = null , // Captures stderr for error messages
2492+ FfiRuntimeHost ? ffiHost = null ) // Set if using in-process FFI hosting
23822493 {
23832494 public Process ? CliProcess => cliProcess ;
23842495 public JsonRpc Rpc => rpc ;
23852496 public ServerRpc Server => field ?? Interlocked . CompareExchange ( ref field , new ( rpc ) , null ) ?? field ;
23862497 public NetworkStream ? NetworkStream => networkStream ;
23872498 public ProcessStderrPump ? StderrPump => stderrPump ;
23882499 public StringBuilder ? StderrBuffer => stderrPump ? . Buffer ;
2500+ public FfiRuntimeHost ? FfiHost => ffiHost ;
23892501 }
23902502
23912503 private sealed class ProcessStderrPump
0 commit comments