/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; using System.Net.Sockets; using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace GitHub.Copilot; /// /// Provides a client for interacting with the Copilot CLI server. /// /// /// /// The manages the connection to the Copilot CLI server and provides /// methods to create and manage conversation sessions. It can either spawn a CLI server process /// or connect to an existing server. /// /// /// The client supports both stdio (default) and TCP transport modes for communication with the CLI server. /// /// /// /// /// // Create a client with default options (spawns CLI server) /// await using var client = new CopilotClient(); /// /// // Create a session /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Handle events /// using var subscription = session.On<SessionEvent>(evt => /// { /// if (evt is AssistantMessageEvent assistantMessage) /// Console.WriteLine(assistantMessage.Data?.Content); /// }); /// /// // Send a message /// await session.SendAsync(new MessageOptions { Prompt = "Hello!" }); /// /// public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { /// /// Minimum protocol version this SDK can communicate with. /// private const int MinProtocolVersion = 3; /// /// Provides a thread-safe collection of active Copilot sessions, indexed by session identifier. /// /// /// This maintains a strong reference to every created on this /// that has not been explicitly disposed or removed. /// internal readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; private readonly RuntimeConnection _connection; private readonly ILogger _logger; private Task? _connectionTask; private bool _disposed; private readonly int? _optionsPort; private readonly string? _optionsHost; private int? _actualPort; private int? _negotiatedProtocolVersion; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); private readonly Func>>? _onListModels; private readonly List _lifecycleHandlers = []; private readonly object _lifecycleHandlersLock = new(); private ServerRpc? _serverRpc; private sealed record LifecycleSubscription(Type EventType, Action Handler); /// /// Gets the typed RPC client for server-scoped methods (no session required). /// /// /// The client must be started before accessing this property. Call before use. /// /// Thrown if the client has been disposed. /// Thrown if the client is not started. public ServerRpc Rpc => _disposed ? throw new ObjectDisposedException(nameof(CopilotClient)) : _serverRpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); /// /// Gets the actual TCP port the runtime is listening on, if using TCP transport. /// public int? RuntimePort => _actualPort; /// /// Creates a new instance of . /// /// Options for creating the client. If null, default options are used. /// /// /// // Default options - spawns the bundled runtime using stdio /// var client = new CopilotClient(); /// /// // Connect to an existing runtime /// var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri("localhost:3000") }); /// /// // Custom runtime path with specific log level /// var client = new CopilotClient(new CopilotClientOptions /// { /// Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot"), /// LogLevel = CopilotLogLevel.Debug /// }); /// /// public CopilotClient(CopilotClientOptions? options = null) { _options = options ?? new(); _connection = _options.Connection ?? RuntimeConnection.ForStdio(); switch (_connection) { case StdioRuntimeConnection: break; case TcpRuntimeConnection tcp: if (tcp.ConnectionToken is { Length: 0 }) { throw new ArgumentException("ConnectionToken must be a non-empty string or null.", nameof(options)); } // Auto-generate a connection token when the SDK spawns the runtime over TCP // so the loopback listener is safe by default. tcp.ConnectionToken ??= Guid.NewGuid().ToString(); break; case UriRuntimeConnection uri: if (string.IsNullOrEmpty(uri.Url)) { throw new ArgumentException("UriRuntimeConnection.Url must be a non-empty string.", nameof(options)); } if (!string.IsNullOrEmpty(_options.GitHubToken) || _options.UseLoggedInUser != null) { throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be combined with RuntimeConnection.ForUri (the existing runtime manages its own auth).", nameof(options)); } var parsed = ParseRuntimeUrl(uri.Url); _optionsHost = parsed.Host; _optionsPort = parsed.Port; break; default: throw new ArgumentException($"Unsupported RuntimeConnection type: {_connection.GetType().Name}", nameof(options)); } _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; } /// /// Parses a runtime URL into a URI with host and port. /// /// The URL to parse. Supports formats: "port", "host:port", "http://host:port". private static Uri ParseRuntimeUrl(string url) { // If it's just a port number, treat as localhost if (int.TryParse(url, out var port)) { return new Uri($"http://localhost:{port}"); } // Add scheme if missing if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { url = "https://" + url; } return new Uri(url); } /// /// Starts the Copilot client and connects to the server. /// /// A that can be used to cancel the operation. /// A representing the asynchronous operation. /// /// If the server is not already running and the client is configured to spawn one (default), it will be started. /// If connecting to an external runtime (via RuntimeConnection.ForUri), only establishes the connection. /// /// /// /// var client = new CopilotClient(); /// await client.StartAsync(); /// // Now ready to create sessions /// /// public Task StartAsync(CancellationToken cancellationToken = default) { return _connectionTask ??= StartCoreAsync(cancellationToken); async Task StartCoreAsync(CancellationToken ct) { _logger.LogDebug("Starting Copilot client"); var startTimestamp = Stopwatch.GetTimestamp(); Connection? connection = null; Process? cliProcess = null; try { if (_connection is UriRuntimeConnection) { // External runtime _actualPort = _optionsPort; connection = await ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); } else { // Child process (stdio or TCP) var (startedProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(ct); cliProcess = startedProcess; _actualPort = portOrNull; connection = await ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.StartAsync transport setup complete. Elapsed={Elapsed}", startTimestamp); // Verify protocol version compatibility await VerifyProtocolVersionAsync(connection, ct); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.StartAsync protocol verification complete. Elapsed={Elapsed}", startTimestamp); var sessionFsTimestamp = Stopwatch.GetTimestamp(); await ConfigureSessionFsAsync(ct); if (_options.SessionFs is not null) { LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.StartAsync session filesystem setup complete. Elapsed={Elapsed}", sessionFsTimestamp); } LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.StartAsync complete. Elapsed={Elapsed}", startTimestamp); return connection; } catch (Exception ex) { if (ex is not OperationCanceledException) { LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex, "CopilotClient.StartAsync failed. Elapsed={Elapsed}", startTimestamp); } if (connection is not null) { await CleanupConnectionAsync(connection, errors: null); } else if (cliProcess is not null) { await CleanupCliProcessAsync(cliProcess, errors: null, _logger); } throw; } } } /// /// Disconnects from the Copilot server and closes all active sessions. /// /// A representing the asynchronous operation. /// /// /// This method performs graceful cleanup: /// /// Closes all active sessions (releases in-memory resources) /// Closes the JSON-RPC connection /// Terminates the CLI server process (if spawned by this client) /// /// /// /// Note: session data on disk is preserved, so sessions can be resumed later. /// To permanently remove session data before stopping, call /// for each session first. /// /// /// Thrown when multiple errors occur during cleanup. /// /// /// await client.StopAsync(); /// /// public async Task StopAsync() { List errors = []; foreach (var session in _sessions.Values.ToArray()) { try { await session.DisposeAsync(); } catch (Exception ex) { errors.Add(new IOException($"Failed to dispose session {session.SessionId}: {ex.Message}", ex)); } } _sessions.Clear(); await CleanupConnectionAsync(errors); ThrowErrors(errors); } /// /// Forces an immediate stop of the client without graceful cleanup. /// /// A representing the asynchronous operation. /// /// Use this when fails or takes too long. This method: /// /// Clears all sessions immediately without destroying them /// Force closes the connection /// Kills the CLI process (if spawned by this client) /// /// /// /// /// // If normal stop hangs, force stop /// var stopTask = client.StopAsync(); /// if (!stopTask.Wait(TimeSpan.FromSeconds(5))) /// { /// await client.ForceStopAsync(); /// } /// /// public async Task ForceStopAsync() { _sessions.Clear(); var errors = new List(); await CleanupConnectionAsync(errors); ThrowErrors(errors); } private static void ThrowErrors(List? errors) { if (errors is not null) { if (errors.Count == 1) { ExceptionDispatchInfo.Throw(errors[0]); } if (errors.Count > 0) { throw new AggregateException(errors); } } } private async Task CleanupConnectionAsync(List? errors) { var connectionTask = _connectionTask; if (connectionTask is null) { return; } _connectionTask = null; Connection ctx; try { ctx = await connectionTask; } catch (Exception ex) { _logger.LogDebug(ex, "Ignoring failed Copilot client startup during cleanup"); return; } await CleanupConnectionAsync(ctx, errors); } private async Task CleanupConnectionAsync(Connection ctx, List? errors) { try { ctx.Rpc.Dispose(); } catch (Exception ex) { AddCleanupError(errors, ex, _logger); } // Clear RPC and models cache _serverRpc = null; _modelsCache = null; if (ctx.NetworkStream is not null) { try { await ctx.NetworkStream.DisposeAsync(); } catch (Exception ex) { AddCleanupError(errors, ex, _logger); } } if (ctx.CliProcess is { } childProcess) { await CleanupCliProcessAsync(childProcess, errors, _logger); } } private static async Task CleanupCliProcessAsync(Process childProcess, List? errors, ILogger? logger) { try { try { if (!childProcess.HasExited) { childProcess.Kill(entireProcessTree: true); await childProcess.WaitForExitAsync(); } } finally { childProcess.Dispose(); } } catch (Exception ex) { AddCleanupError(errors, ex, logger); } } private static void AddCleanupError(List? errors, Exception ex, ILogger? logger) { if (errors is not null) { errors.Add(ex); } else { logger?.LogDebug(ex, "Error while cleaning up Copilot CLI connection"); } } private static (SystemMessageConfig? wireConfig, Dictionary>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage) { if (systemMessage?.Mode != SystemMessageMode.Customize || systemMessage.Sections == null) { return (systemMessage, null); } var callbacks = new Dictionary>>(); var wireSections = new Dictionary(); foreach (var (sectionId, sectionOverride) in systemMessage.Sections) { if (sectionOverride.Transform != null) { callbacks[sectionId.Value] = sectionOverride.Transform; wireSections[sectionId] = new SectionOverride { Action = SectionOverrideAction.Transform }; } else { wireSections[sectionId] = sectionOverride; } } if (callbacks.Count == 0) { return (systemMessage, null); } var wireConfig = new SystemMessageConfig { Mode = systemMessage.Mode, Content = systemMessage.Content, Sections = wireSections }; return (wireConfig, callbacks); } /// /// Creates a new Copilot session with the specified configuration. /// /// Configuration for the session. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected, /// this will automatically start the connection. /// /// /// /// // Basic session /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Session with model and tools /// var session = await client.CreateSessionAsync(new() /// { /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Model = "gpt-4", /// Tools = [AIFunctionFactory.Create(MyToolMethod)] /// }); /// /// public async Task CreateSessionAsync(SessionConfig config, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(config); var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); var sessionId = config.SessionId ?? Guid.NewGuid().ToString(); // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var setupTimestamp = Stopwatch.GetTimestamp(); var session = new CopilotSession( sessionId, connection.Rpc, _logger, this); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest); session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitchRequest); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } if (transformCallbacks != null) { session.RegisterTransformCallbacks(transformCallbacks); } if (config.OnEvent != null) { session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.CreateSessionAsync local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}", setupTimestamp, sessionId, config.Tools?.Count ?? 0, config.Commands?.Count ?? 0, hasHooks); try { var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext(); var request = new CreateSessionRequest( config.Model, sessionId, config.ClientName, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, config.OnExitPlanModeRequest != null ? true : null, config.OnAutoModeSwitchRequest != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, "direct", config.CustomAgents, config.DefaultAgent, config.Agent, config.ConfigDir, config.EnableConfigDiscovery, config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities, GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, Cloud: config.Cloud, InstructionDirectories: config.InstructionDirectories); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( connection.Rpc, "session.create", [request], cancellationToken); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.CreateSessionAsync session creation request completed successfully. Elapsed={Elapsed}, SessionId={SessionId}", rpcTimestamp, sessionId); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } catch (Exception ex) { session.RemoveFromClient(); if (ex is not OperationCanceledException) { LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex, "CopilotClient.CreateSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, sessionId); } throw; } LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.CreateSessionAsync complete. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, sessionId); return session; } /// /// Resumes an existing Copilot session with the specified configuration. /// /// The ID of the session to resume. /// Configuration for the resumed session. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// Thrown when the session does not exist or the client is not connected. /// /// This allows you to continue a previous conversation, maintaining all conversation history. /// The session must have been previously created and not deleted. /// /// /// /// // Resume a previous session /// var session = await client.ResumeSessionAsync("session-123", new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Resume with new tools /// var session = await client.ResumeSessionAsync("session-123", new() /// { /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Tools = [AIFunctionFactory.Create(MyNewToolMethod)] /// }); /// /// public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig config, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); ArgumentNullException.ThrowIfNull(config); var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var setupTimestamp = Stopwatch.GetTimestamp(); var session = new CopilotSession( sessionId, connection.Rpc, _logger, client: this); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest); session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitchRequest); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } if (transformCallbacks != null) { session.RegisterTransformCallbacks(transformCallbacks); } if (config.OnEvent != null) { session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.ResumeSessionAsync local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}", setupTimestamp, sessionId, config.Tools?.Count ?? 0, config.Commands?.Count ?? 0, hasHooks); try { var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext(); var request = new ResumeSessionRequest( sessionId, config.ClientName, config.Model, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, config.OnExitPlanModeRequest != null ? true : null, config.OnAutoModeSwitchRequest != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.ConfigDir, config.EnableConfigDiscovery, config.SuppressResumeEvent is true ? true : null, config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, "direct", config.CustomAgents, config.DefaultAgent, config.Agent, config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities, GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, ContinuePendingWork: config.ContinuePendingWork, InstructionDirectories: config.InstructionDirectories); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( connection.Rpc, "session.resume", [request], cancellationToken); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.ResumeSessionAsync session resume request completed successfully. Elapsed={Elapsed}, SessionId={SessionId}", rpcTimestamp, sessionId); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } catch (Exception ex) { session.RemoveFromClient(); if (ex is not OperationCanceledException) { LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex, "CopilotClient.ResumeSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, sessionId); } throw; } LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.ResumeSessionAsync complete. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, sessionId); return session; } /// /// Validates the health of the connection by sending a ping request. /// /// An optional message that will be reflected back in the response. /// A that can be used to cancel the operation. /// A task that resolves with the containing the message and server timestamp. /// Thrown when the client is not connected. /// /// /// var response = await client.PingAsync("health check"); /// Console.WriteLine($"Server responded at {response.Timestamp}"); /// /// public async Task PingAsync(string? message = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest { Message = message }], cancellationToken); } /// /// Gets CLI status including version and protocol information. /// /// A that can be used to cancel the operation. /// A task that resolves with the status response containing version and protocol version. /// Thrown when the client is not connected. public async Task GetStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "status.get", [], cancellationToken); } /// /// Gets current authentication status. /// /// A that can be used to cancel the operation. /// A task that resolves with the authentication status. /// Thrown when the client is not connected. public async Task GetAuthStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "auth.getStatus", [], cancellationToken); } /// /// Lists available models with their metadata. /// /// A that can be used to cancel the operation. /// A task that resolves with a list of available models. /// /// Results are cached after the first successful call to avoid rate limiting. /// The cache is cleared when the client disconnects. /// /// Thrown when the client is not connected or not authenticated. public async Task> ListModelsAsync(CancellationToken cancellationToken = default) { await _modelsCacheLock.WaitAsync(cancellationToken); try { // Check cache (already inside lock) if (_modelsCache is null) { IList models; if (_onListModels is not null) { // Use custom handler instead of CLI RPC models = await _onListModels(cancellationToken); } else { var connection = await EnsureConnectedAsync(cancellationToken); // Cache miss - fetch from backend while holding lock var response = await InvokeRpcAsync( connection.Rpc, "models.list", [], cancellationToken); models = response.Models; } // Update cache before releasing lock (copy to prevent external mutation) _modelsCache = [.. models]; } return [.. _modelsCache]; // Return a copy to prevent cache mutation } finally { _modelsCacheLock.Release(); } } /// /// Gets the ID of the most recently used session. /// /// A that can be used to cancel the operation. /// A task that resolves with the session ID, or null if no sessions exist. /// Thrown when the client is not connected. /// /// /// var lastId = await client.GetLastSessionIdAsync(); /// if (lastId != null) /// { /// var session = await client.ResumeSessionAsync(lastId, new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// public async Task GetLastSessionIdAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getLastId", [], cancellationToken); return response.SessionId; } /// /// Permanently deletes a session and all its data from disk, including /// conversation history, planning state, and artifacts. /// /// The ID of the session to delete. /// A that can be used to cancel the operation. /// A task that represents the asynchronous delete operation. /// Thrown when the session does not exist or deletion fails. /// /// Unlike , which only releases in-memory /// resources and preserves session data for later resumption, this method is /// irreversible. The session cannot be resumed after deletion. /// /// /// /// await client.DeleteSessionAsync("session-123"); /// /// public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.delete", [new DeleteSessionRequest(sessionId)], cancellationToken); if (!response.Success) { throw new InvalidOperationException($"Failed to delete session {sessionId}: {response.Error}"); } RemoveSession(sessionId); } /// /// Lists all sessions known to the Copilot server. /// /// Optional filter to narrow down the session list by cwd, git root, repository, or branch. /// A that can be used to cancel the operation. /// A task that resolves with a list of for all available sessions. /// Thrown when the client is not connected. /// /// /// var sessions = await client.ListSessionsAsync(); /// foreach (var session in sessions) /// { /// Console.WriteLine($"{session.SessionId}: {session.Summary}"); /// } /// /// public async Task> ListSessionsAsync(SessionListFilter? filter = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.list", [new ListSessionsRequest(filter)], cancellationToken); return response.Sessions; } /// /// Gets metadata for a specific session by ID. /// /// /// This provides an efficient O(1) lookup of a single session's metadata /// instead of listing all sessions. /// /// The ID of the session to look up. /// A that can be used to cancel the operation. /// A task that resolves with the , or null if the session was not found. /// Thrown when the client is not connected. /// /// /// var metadata = await client.GetSessionMetadataAsync("session-123"); /// if (metadata != null) /// { /// Console.WriteLine($"Session started at: {metadata.StartTime}"); /// } /// /// public async Task GetSessionMetadataAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getMetadata", [new GetSessionMetadataRequest(sessionId)], cancellationToken); return response.Session; } /// /// Gets the ID of the session currently displayed in the TUI. /// /// /// This is only available when connecting to a server running in TUI+server mode /// (--ui-server). /// /// A token to cancel the operation. /// The session ID, or null if no foreground session is set. /// /// /// var sessionId = await client.GetForegroundSessionIdAsync(); /// if (sessionId != null) /// { /// Console.WriteLine($"TUI is displaying session: {sessionId}"); /// } /// /// public async Task GetForegroundSessionIdAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getForeground", [], cancellationToken); return response.SessionId; } /// /// Requests the TUI to switch to displaying the specified session. /// /// /// This is only available when connecting to a server running in TUI+server mode /// (--ui-server). /// /// The ID of the session to display in the TUI. /// A token to cancel the operation. /// Thrown if the operation fails. /// /// /// await client.SetForegroundSessionIdAsync("session-123"); /// /// public async Task SetForegroundSessionIdAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionId); var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.setForeground", [new SetForegroundSessionRequest(sessionId)], cancellationToken); if (!response.Success) { throw new InvalidOperationException(response.Error ?? "Failed to set foreground session"); } } /// /// Subscribes to session lifecycle events of a specific kind. /// /// /// The lifecycle event type to listen for. Pass a derived type such as /// to filter by kind, or /// to receive every lifecycle event. /// /// A callback invoked when a matching lifecycle event arrives. /// An that, when disposed, unsubscribes the handler. /// /// /// using var sub = client.OnLifecycle<SessionForegroundEvent>(evt => /// { /// Console.WriteLine($"Session {evt.SessionId} is now in foreground"); /// }); /// /// public IDisposable OnLifecycle(Action handler) where T : SessionLifecycleEvent { ArgumentNullException.ThrowIfNull(handler); var subscription = new LifecycleSubscription(typeof(T), evt => handler((T)evt)); lock (_lifecycleHandlersLock) { _lifecycleHandlers.Add(subscription); } return new ActionDisposable(() => { lock (_lifecycleHandlersLock) { _lifecycleHandlers.Remove(subscription); } }); } private void DispatchLifecycleEvent(SessionLifecycleEvent evt) { List snapshot; lock (_lifecycleHandlersLock) { snapshot = [.. _lifecycleHandlers]; } var eventType = evt.GetType(); foreach (var subscription in snapshot) { if (!subscription.EventType.IsAssignableFrom(eventType)) { continue; } try { subscription.Handler(evt); } catch { /* Ignore handler errors */ } } } internal static Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return InvokeRpcAsync(rpc, method, args, null, cancellationToken); } internal static Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return InvokeRpcAsync(rpc, method, args, null, cancellationToken); } internal static Task InvokeRpcAsync(SessionRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return InvokeRpcAsync(rpc.Session.JsonRpc, method, args, cancellationToken); } internal static Task InvokeRpcAsync(SessionRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return InvokeRpcAsync(rpc, method, args, cancellationToken); } internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { try { return await rpc.InvokeAsync(method, args, cancellationToken); } catch (ConnectionLostException ex) { string? stderrOutput = null; if (stderrBuffer is not null) { lock (stderrBuffer) { stderrOutput = stderrBuffer.ToString().Trim(); } } if (!string.IsNullOrEmpty(stderrOutput)) { throw new IOException(FormatCliExitedMessage("CLI process exited unexpectedly.", stderrOutput!), ex); } throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); } catch (RemoteRpcException ex) { throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); } } private static string FormatCliExitedMessage(string message, string stderrOutput) { return string.IsNullOrEmpty(stderrOutput) ? message : $"{message}\nstderr: {stderrOutput}"; } [LoggerMessage( Level = LogLevel.Information, Message = "CopilotClient.StartCliServerAsync starting Copilot CLI. CliPath={CliPath}, Executable={Executable}, CliPathSource={CliPathSource}, UseStdio={UseStdio}, Port={Port}")] private static partial void LogStartingCopilotCli(ILogger logger, string cliPath, string executable, string cliPathSource, bool useStdio, int? port); [LoggerMessage( Level = LogLevel.Information, Message = "CopilotClient.ConnectToServerAsync connecting to CLI server. Host={Host}, Port={Port}")] private static partial void LogConnectingToCliServer(ILogger logger, string host, int port); private static IOException CreateCliExitedException(string message, StringBuilder stderrBuffer) { string stderrOutput; lock (stderrBuffer) { stderrOutput = stderrBuffer.ToString().Trim(); } return new IOException(FormatCliExitedMessage(message, stderrOutput)); } private Task EnsureConnectedAsync(CancellationToken cancellationToken) { // If already started or starting, this will return the existing task return (Task)StartAsync(cancellationToken); } private async Task ConfigureSessionFsAsync(CancellationToken cancellationToken) { if (_options.SessionFs is null) { return; } await Rpc.SessionFs.SetProviderAsync( _options.SessionFs.InitialWorkingDirectory, _options.SessionFs.SessionStatePath, _options.SessionFs.Conventions, _options.SessionFs.Capabilities, cancellationToken: cancellationToken); } private void ConfigureSessionFsHandlers(CopilotSession session, Func? createSessionFsHandler) { if (_options.SessionFs is null) { return; } if (createSessionFsHandler is null) { throw new InvalidOperationException( "CreateSessionFsProvider is required in the session config when CopilotClientOptions.SessionFs is configured."); } var provider = createSessionFsHandler(session) ?? throw new InvalidOperationException("CreateSessionFsProvider returned null."); if (_options.SessionFs.Capabilities?.Sqlite == true && provider is not ISessionFsSqliteProvider) { throw new InvalidOperationException( "SessionFsConfig declares capabilities.sqlite but the provider does not implement ISessionFsSqliteProvider."); } session.ClientSessionApis.SessionFs = provider; } private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { var handshakeTimestamp = Stopwatch.GetTimestamp(); var usedFallbackPing = false; var maxVersion = SdkProtocolVersion.GetVersion(); int? serverVersion; try { var token = _connection switch { TcpRuntimeConnection tcp => tcp.ConnectionToken, UriRuntimeConnection uri => uri.ConnectionToken, _ => null, }; var connectResponse = await InvokeRpcAsync( connection.Rpc, "connect", [new ConnectRequest { Token = token }], connection.StderrBuffer, cancellationToken); serverVersion = (int)connectResponse.ProtocolVersion; } catch (IOException ex) when (ex.InnerException is RemoteRpcException remoteEx && IsUnsupportedConnectMethod(remoteEx)) { // Legacy server without `connect`; fall back to `ping`. A token, if any, // is silently dropped — the legacy server can't enforce one. usedFallbackPing = true; var pingResponse = await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); serverVersion = pingResponse.ProtocolVersion; } if (!serverVersion.HasValue) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } if (serverVersion.Value < MinProtocolVersion || serverVersion.Value > maxVersion) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server reports version {serverVersion.Value}. " + $"Please update your SDK or server to ensure compatibility."); } _negotiatedProtocolVersion = serverVersion.Value; LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.VerifyProtocolVersionAsync protocol handshake complete. Elapsed={Elapsed}, ProtocolVersion={ProtocolVersion}, UsedFallbackPing={UsedFallbackPing}", handshakeTimestamp, serverVersion.Value, usedFallbackPing); } private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) { return ex.ErrorCode == RemoteRpcException.MethodNotFoundErrorCode || string.Equals(ex.Message, "Unhandled method connect", StringComparison.Ordinal); } private async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CancellationToken cancellationToken) { var options = _options; var logger = _logger; var childProcessConnection = (ChildProcessRuntimeConnection)_connection; var tcpConnection = _connection as TcpRuntimeConnection; var useStdio = _connection is StdioRuntimeConnection; // Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled runtime - no PATH fallback var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); var cliPath = childProcessConnection.Path ?? envCliPath ?? GetBundledCliPath(out var searchedPath) ?? throw new InvalidOperationException($"Copilot runtime not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit RuntimeConnection.ForStdio(path: ...) / RuntimeConnection.ForTcp(path: ...)."); var cliPathSource = childProcessConnection.Path is not null ? "Options" : envCliPath is not null ? "Environment" : "Bundled"; var args = new List(); if (childProcessConnection.Args != null) { args.AddRange(childProcessConnection.Args); } args.AddRange(["--headless", "--no-auto-update"]); if (options.LogLevel is { } logLevel && !string.IsNullOrEmpty(logLevel.Value)) { args.AddRange(["--log-level", logLevel.Value]); } if (useStdio) { args.Add("--stdio"); } else if (tcpConnection is { Port: > 0 } tcp) { args.AddRange(["--port", tcp.Port.ToString(CultureInfo.InvariantCulture)]); } // Add auth-related flags if (!string.IsNullOrEmpty(options.GitHubToken)) { args.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); } // Default UseLoggedInUser to false when GitHubToken is provided var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GitHubToken); if (!useLoggedInUser) { args.Add("--no-auto-login"); } if (options.SessionIdleTimeoutSeconds is > 0) { args.AddRange(["--session-idle-timeout", options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]); } if (options.EnableRemoteSessions) { args.Add("--remote"); } var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var configuredPort = useStdio ? (int?)null : tcpConnection?.Port; LogStartingCopilotCli(logger, cliPath, fileName, cliPathSource, useStdio, configuredPort); var startInfo = new ProcessStartInfo { FileName = fileName, Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)), UseShellExecute = false, RedirectStandardInput = useStdio, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = options.WorkingDirectory, CreateNoWindow = true }; if (options.Environment != null) { startInfo.Environment.Clear(); foreach (var (key, value) in options.Environment) { startInfo.Environment[key] = value; } } startInfo.Environment.Remove("NODE_DEBUG"); // Set auth token in environment if provided if (!string.IsNullOrEmpty(options.GitHubToken)) { startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken; } if (tcpConnection?.ConnectionToken is { Length: > 0 } token) { startInfo.Environment["COPILOT_CONNECTION_TOKEN"] = token; } if (!string.IsNullOrEmpty(options.BaseDirectory)) { startInfo.Environment["COPILOT_HOME"] = options.BaseDirectory; } // Set telemetry environment variables if configured if (options.Telemetry is { } telemetry) { startInfo.Environment["COPILOT_OTEL_ENABLED"] = "true"; if (telemetry.OtlpEndpoint is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint; if (telemetry.FilePath is not null) startInfo.Environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath; if (telemetry.ExporterType is not null) startInfo.Environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType; if (telemetry.SourceName is not null) startInfo.Environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName; if (telemetry.CaptureContent is { } capture) startInfo.Environment["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = capture ? "true" : "false"; } Process? cliProcess = null; try { cliProcess = new Process { StartInfo = startInfo }; var spawnTimestamp = Stopwatch.GetTimestamp(); cliProcess.Start(); LoggingHelpers.LogTiming(logger, LogLevel.Debug, null, "CopilotClient.StartCliServerAsync subprocess spawned. Elapsed={Elapsed}", spawnTimestamp); // Capture stderr for error messages and forward to logger var stderrBuffer = new StringBuilder(); var stderrReader = Task.Run(async () => { while (true) { var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); if (line is null) { break; } lock (stderrBuffer) { stderrBuffer.AppendLine(line); } logger.LogWarning("[CLI] {Line}", line); } }, cancellationToken); var detectedLocalhostTcpPort = (int?)null; if (!useStdio) { // Wait for port announcement var portWaitTimestamp = Stopwatch.GetTimestamp(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); while (!cts.Token.IsCancellationRequested) { var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token); if (line is null) { await stderrReader; throw CreateCliExitedException("Runtime process exited unexpectedly", stderrBuffer); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("[CLI] {Line}", line); } if (ListeningOnPortRegex().Match(line) is { Success: true } match) { detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); LoggingHelpers.LogTiming(logger, LogLevel.Debug, null, "CopilotClient.StartCliServerAsync TCP port wait complete. Elapsed={Elapsed}, Port={Port}", portWaitTimestamp, detectedLocalhostTcpPort.Value); break; } } } return (cliProcess, detectedLocalhostTcpPort, stderrBuffer); } catch { if (cliProcess is not null) { await CleanupCliProcessAsync(cliProcess, errors: null, logger); } throw; } } private static string? GetBundledCliPath(out string searchedPath) { var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; // Always use portable RID (e.g., linux-x64) to match the build-time placement, // since distro-specific RIDs (e.g., ubuntu.24.04-x64) are normalized at build time. var rid = GetPortableRid() ?? Path.GetFileName(RuntimeInformation.RuntimeIdentifier); searchedPath = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", binaryName); return File.Exists(searchedPath) ? searchedPath : null; } private static string? GetPortableRid() { string os; if (OperatingSystem.IsWindows()) os = "win"; else if (OperatingSystem.IsLinux()) os = "linux"; else if (OperatingSystem.IsMacOS()) os = "osx"; else return null; var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch { System.Runtime.InteropServices.Architecture.X64 => "x64", System.Runtime.InteropServices.Architecture.Arm64 => "arm64", _ => null, }; return arch != null ? $"{os}-{arch}" : null; } private static (string FileName, IEnumerable Args) ResolveCliCommand(string cliPath, IEnumerable args) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); if (isJsFile) { return ("node", new[] { cliPath }.Concat(args)); } return (cliPath, args); } private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { var setupTimestamp = Stopwatch.GetTimestamp(); Stream inputStream, outputStream; NetworkStream? networkStream = null; if (_connection is StdioRuntimeConnection) { if (cliProcess == null) { throw new InvalidOperationException("Runtime process not started"); } inputStream = cliProcess.StandardOutput.BaseStream; outputStream = cliProcess.StandardInput.BaseStream; } else { if (tcpHost is null || tcpPort is null) { throw new InvalidOperationException("Cannot connect because TCP host or port are not available"); } var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); try { var tcpConnectTimestamp = Stopwatch.GetTimestamp(); LogConnectingToCliServer(_logger, tcpHost, tcpPort.Value); await socket.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.ConnectToServerAsync TCP connect complete. Elapsed={Elapsed}, Host={Host}, Port={Port}", tcpConnectTimestamp, tcpHost, tcpPort.Value); } catch { socket.Dispose(); throw; } inputStream = outputStream = networkStream = new NetworkStream(socket, ownsSocket: true); } var rpc = new JsonRpc( outputStream, inputStream, SerializerOptionsForMessageFormatter, _logger); var handler = new RpcHandler(this); rpc.SetLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.SetLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); rpc.SetLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.SetLocalRpcMethod("exitPlanMode.request", handler.OnExitPlanModeRequest); rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest); rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => { var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return session.ClientSessionApis; }); rpc.StartListening(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}", setupTimestamp); _serverRpc = new ServerRpc(rpc); return new Connection(rpc, cliProcess, networkStream, stderrBuffer); } private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions(); /// /// Converts an arbitrary value into the representation that wire /// DTOs use for opaque-JSON fields. Pass-through for , otherwise /// serializes the runtime type using the shared JSON-RPC serializer options so that any /// type registered in the SDK's source-generated contexts (e.g. primitives, /// Dictionary<string, object>, generated DTOs) is supported. /// public static JsonElement? ToJsonElementForWire(object? value) => value switch { null => null, JsonElement je => je, _ => JsonSerializer.SerializeToElement(value, SerializerOptionsForMessageFormatter.GetTypeInfo(value.GetType())) }; private static JsonSerializerOptions CreateSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; options.TypeInfoResolverChain.Add(ClientJsonContext.Default); options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); options.TypeInfoResolverChain.Add(GitHub.Copilot.Rpc.RpcJsonContext.Default); options.MakeReadOnly(); return options; } internal CopilotSession? GetSession(string sessionId) { _sessions.TryGetValue(sessionId, out var session); return session; } private void RegisterSession(CopilotSession session) { if (!_sessions.TryAdd(session.SessionId, session)) { throw new InvalidOperationException($"Session '{session.SessionId}' is already tracked by this client."); } } private void RemoveSession(string sessionId) { _sessions.TryRemove(sessionId, out _); } /// /// Disposes the synchronously. /// /// /// Prefer using for better performance in async contexts. /// public void Dispose() { DisposeAsync().AsTask().GetAwaiter().GetResult(); } /// /// Disposes the asynchronously. /// /// A representing the asynchronous dispose operation. /// /// This method calls to immediately release all resources. /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await ForceStopAsync(); } private class RpcHandler(CopilotClient client) { public void OnSessionEvent(string sessionId, JsonElement? @event) { var session = client.GetSession(sessionId); if (session != null && @event != null) { var evt = SessionEvent.FromJson(@event.Value.GetRawText()); if (evt != null) { session.DispatchEvent(evt); } } } public void OnSessionLifecycle(string type, string sessionId, JsonElement? metadata) { SessionLifecycleEvent evt = type switch { "session.created" => new SessionCreatedEvent(), "session.deleted" => new SessionDeletedEvent(), "session.updated" => new SessionUpdatedEvent(), "session.foreground" => new SessionForegroundEvent(), "session.background" => new SessionBackgroundEvent(), _ => new SessionLifecycleEvent() }; evt.Type = type; evt.SessionId = sessionId; if (metadata is not null) { evt.Metadata = JsonSerializer.Deserialize( metadata.Value.GetRawText(), TypesJsonContext.Default.SessionLifecycleEventMetadata); } client.DispatchLifecycleEvent(evt); } public async ValueTask OnUserInputRequest(string sessionId, string question, IList? choices = null, bool? allowFreeform = null) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var request = new UserInputRequest { Question = question, Choices = choices, AllowFreeform = allowFreeform }; var result = await session.HandleUserInputRequestAsync(request); return new UserInputRequestResponse(result.Answer, result.WasFreeform); } public async ValueTask OnExitPlanModeRequest( string sessionId, string summary, string? planContent = null, IList? actions = null, string? recommendedAction = null) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var request = new ExitPlanModeRequest { Summary = summary, PlanContent = planContent, Actions = actions ?? [], RecommendedAction = recommendedAction ?? "autopilot" }; return await session.HandleExitPlanModeRequestAsync(request); } public async ValueTask OnAutoModeSwitchRequest( string sessionId, string? errorCode = null, double? retryAfterSeconds = null) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var response = await session.HandleAutoModeSwitchRequestAsync(new AutoModeSwitchRequest { ErrorCode = errorCode, RetryAfterSeconds = retryAfterSeconds }); return new AutoModeSwitchRequestResponse(response); } public async ValueTask OnHooksInvoke(string sessionId, string hookType, JsonElement input) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var output = await session.HandleHooksInvokeAsync(hookType, input); return new HooksInvokeResponse(output); } public async ValueTask OnSystemMessageTransform(string sessionId, JsonElement sections) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return await session.HandleSystemMessageTransformAsync(sections); } } private class Connection( JsonRpc rpc, Process? cliProcess, // Set if we created the child process NetworkStream? networkStream, // Set if using TCP StringBuilder? stderrBuffer = null) // Captures stderr for error messages { public Process? CliProcess => cliProcess; public JsonRpc Rpc => rpc; public NetworkStream? NetworkStream => networkStream; public StringBuilder? StderrBuffer => stderrBuffer; } private static class ProcessArgumentEscaper { public static string Escape(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; if (!arg.Contains(' ') && !arg.Contains('"')) return arg; return "\"" + arg.Replace("\"", "\\\"") + "\""; } } // Request/Response types for RPC internal record CreateSessionRequest( string? Model, string? SessionId, string? ClientName, string? ReasoningEffort, IList? Tools, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? RequestExitPlanMode, bool? RequestAutoModeSwitch, bool? Hooks, string? WorkingDirectory, bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, string? Agent, string? ConfigDir, bool? EnableConfigDiscovery, IList? SkillDirectories, IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, CloudSessionOptions? Cloud = null, IList? InstructionDirectories = null); internal record ToolDefinition( string Name, string? Description, JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, bool? SkipPermission = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null); } } internal record CreateSessionResponse( string SessionId, string? WorkspacePath, SessionCapabilities? Capabilities = null); internal record ResumeSessionRequest( string SessionId, string? ClientName, string? Model, string? ReasoningEffort, IList? Tools, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? RequestExitPlanMode, bool? RequestAutoModeSwitch, bool? Hooks, string? WorkingDirectory, string? ConfigDir, bool? EnableConfigDiscovery, bool? SuppressResumeEvent, bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, string? Agent, IList? SkillDirectories, IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, bool? ContinuePendingWork = null, IList? InstructionDirectories = null); internal record ResumeSessionResponse( string SessionId, string? WorkspacePath, SessionCapabilities? Capabilities = null); internal record CommandWireDefinition( string Name, string? Description); internal record GetLastSessionIdResponse( string? SessionId); internal record DeleteSessionRequest( string SessionId); internal record DeleteSessionResponse( bool Success, string? Error); internal record ListSessionsRequest( SessionListFilter? Filter); internal record ListSessionsResponse( List Sessions); internal record GetSessionMetadataRequest( string SessionId); internal record GetSessionMetadataResponse( SessionMetadata? Session); internal record SetForegroundSessionRequest( string SessionId); internal record UserInputRequestResponse( string Answer, bool WasFreeform); internal record AutoModeSwitchRequestResponse( AutoModeSwitchResponse Response); internal record HooksInvokeResponse( object? Output); [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(CreateSessionRequest))] [JsonSerializable(typeof(CreateSessionResponse))] [JsonSerializable(typeof(AutoModeSwitchRequest))] [JsonSerializable(typeof(AutoModeSwitchRequestResponse))] [JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(CustomAgentConfig))] [JsonSerializable(typeof(DeleteSessionRequest))] [JsonSerializable(typeof(DeleteSessionResponse))] [JsonSerializable(typeof(ExitPlanModeRequest))] [JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(GetLastSessionIdResponse))] [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionCapabilities))] [JsonSerializable(typeof(SessionUiCapabilities))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionRequest))] [JsonSerializable(typeof(SystemMessageConfig))] [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(CommandWireDefinition))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] [JsonSerializable(typeof(UserInputRequestResponse))] [JsonSerializable(typeof(UserInputRequest))] [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; #if NET8_0_OR_GREATER [GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)] private static partial Regex ListeningOnPortRegex(); #else private static readonly Regex s_listeningOnPortRegex = new(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase); private static Regex ListeningOnPortRegex() => s_listeningOnPortRegex; #endif } /// /// Wraps a as to pass structured tool results /// back through Microsoft.Extensions.AI without JSON serialization. /// /// The tool result to wrap. public sealed class ToolResultAIContent(ToolResultObject toolResult) : AIContent { /// /// Gets the underlying . /// public ToolResultObject Result => toolResult; }