/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;
using System.Collections.Concurrent;
using System.Data;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace GitHub.Copilot.SDK;
///
/// 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 SessionConfig { Model = "gpt-4" });
///
/// // Handle events
/// using var subscription = session.On(evt =>
/// {
/// if (evt is AssistantMessageEvent assistantMessage)
/// Console.WriteLine(assistantMessage.Data?.Content);
/// });
///
/// // Send a message
/// await session.SendAsync(new MessageOptions { Prompt = "Hello!" });
///
///
public partial class CopilotClient : IDisposable, IAsyncDisposable
{
private readonly ConcurrentDictionary _sessions = new();
private readonly CopilotClientOptions _options;
private readonly ILogger _logger;
private Task? _connectionTask;
private bool _disposed;
private readonly int? _optionsPort;
private readonly string? _optionsHost;
///
/// Creates a new instance of .
///
/// Options for creating the client. If null, default options are used.
/// Thrown when mutually exclusive options are provided (e.g., CliUrl with UseStdio or CliPath).
///
///
/// // Default options - spawns CLI server using stdio
/// var client = new CopilotClient();
///
/// // Connect to an existing server
/// var client = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:3000" });
///
/// // Custom CLI path with specific log level
/// var client = new CopilotClient(new CopilotClientOptions
/// {
/// CliPath = "/usr/local/bin/copilot",
/// LogLevel = "debug"
/// });
///
///
public CopilotClient(CopilotClientOptions? options = null)
{
_options = options ?? new();
// Validate mutually exclusive options
if (!string.IsNullOrEmpty(_options.CliUrl) && (_options.UseStdio || _options.CliPath != null))
{
throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath");
}
_logger = _options.Logger ?? NullLogger.Instance;
// Parse CliUrl if provided
if (!string.IsNullOrEmpty(_options.CliUrl))
{
var uri = ParseCliUrl(_options.CliUrl!);
_optionsHost = uri.Host;
_optionsPort = uri.Port;
}
}
///
/// Parses a CLI URL into a URI with host and port.
///
/// The URL to parse. Supports formats: "port", "host:port", "http://host:port".
/// A containing the parsed host and port.
private static Uri ParseCliUrl(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 server (via CliUrl), only establishes the connection.
///
///
/// This method is called automatically when creating a session if is true (default).
///
///
///
///
/// var client = new CopilotClient(new CopilotClientOptions { AutoStart = false });
/// 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");
Task result;
if (_optionsHost is not null && _optionsPort is not null)
{
// External server (TCP)
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, ct);
}
else
{
// Child process (stdio or TCP)
var (cliProcess, portOrNull) = await StartCliServerAsync(_options, _logger, ct);
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, ct);
}
var connection = await result;
// Verify protocol version compatibility
await VerifyProtocolVersionAsync(connection, ct);
_logger.LogInformation("Copilot client connected");
return connection;
}
}
///
/// Disconnects from the Copilot server and stops all active sessions.
///
/// A representing the asynchronous operation.
///
///
/// This method performs graceful cleanup:
///
/// Destroys all active sessions
/// Closes the JSON-RPC connection
/// Terminates the CLI server process (if spawned by this client)
///
///
///
/// Thrown when multiple errors occur during cleanup.
///
///
/// await client.StopAsync();
///
///
public async Task StopAsync()
{
var errors = new List();
foreach (var session in _sessions.Values.ToArray())
{
try
{
await session.DisposeAsync();
}
catch (Exception ex)
{
errors.Add(new Exception($"Failed to destroy session {session.SessionId}: {ex.Message}", ex));
}
}
_sessions.Clear();
await CleanupConnectionAsync(errors);
_connectionTask = null;
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()
{
var errors = new List();
_sessions.Clear();
await CleanupConnectionAsync(errors);
_connectionTask = null;
ThrowErrors(errors);
}
private static void ThrowErrors(List errors)
{
if (errors.Count == 1)
{
throw errors[0];
}
else if (errors.Count > 0)
{
throw new AggregateException(errors);
}
}
private async Task CleanupConnectionAsync(List? errors)
{
if (_connectionTask is null)
{
return;
}
var ctx = await _connectionTask;
_connectionTask = null;
try { ctx.Rpc.Dispose(); }
catch (Exception ex) { errors?.Add(ex); }
if (ctx.NetworkStream is not null)
{
try { await ctx.NetworkStream.DisposeAsync(); }
catch (Exception ex) { errors?.Add(ex); }
}
if (ctx.TcpClient is not null)
{
try { ctx.TcpClient.Dispose(); }
catch (Exception ex) { errors?.Add(ex); }
}
if (ctx.CliProcess is { } childProcess)
{
try
{
if (!childProcess.HasExited) childProcess.Kill();
childProcess.Dispose();
}
catch (Exception ex) { errors?.Add(ex); }
}
}
///
/// Creates a new Copilot session with the specified configuration.
///
/// Configuration for the session. If null, default settings are used.
/// A that can be used to cancel the operation.
/// A task that resolves to provide the .
/// Thrown when the client is not connected and AutoStart is disabled, or when a session with the same ID already exists.
///
/// Sessions maintain conversation state, handle events, and manage tool execution.
/// If the client is not connected and is enabled (default),
/// this will automatically start the connection.
///
///
///
/// // Basic session
/// var session = await client.CreateSessionAsync();
///
/// // Session with model and tools
/// var session = await client.CreateSessionAsync(new SessionConfig
/// {
/// Model = "gpt-4",
/// Tools = [AIFunctionFactory.Create(MyToolMethod)]
/// });
///
///
public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var request = new CreateSessionRequest(
config?.Model,
config?.SessionId,
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config?.SystemMessage,
config?.AvailableTools,
config?.ExcludedTools,
config?.Provider,
config?.OnPermissionRequest != null ? true : null,
config?.Streaming == true ? true : null,
config?.McpServers,
config?.CustomAgents,
config?.ConfigDir,
config?.SkillDirectories,
config?.DisabledSkills,
config?.InfiniteSessions);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"session.create", [request], cancellationToken);
var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
session.RegisterTools(config?.Tools ?? []);
if (config?.OnPermissionRequest != null)
{
session.RegisterPermissionHandler(config.OnPermissionRequest);
}
if (!_sessions.TryAdd(response.SessionId, session))
{
throw new InvalidOperationException($"Session {response.SessionId} already exists");
}
return session;
}
///
/// Resumes an existing Copilot session with the specified configuration.
///
/// The ID of the session to resume.
/// Configuration for the resumed session. If null, default settings are used.
/// 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");
///
/// // Resume with new tools
/// var session = await client.ResumeSessionAsync("session-123", new ResumeSessionConfig
/// {
/// Tools = [AIFunctionFactory.Create(MyNewToolMethod)]
/// });
///
///
public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null, CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var request = new ResumeSessionRequest(
sessionId,
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config?.Provider,
config?.OnPermissionRequest != null ? true : null,
config?.Streaming == true ? true : null,
config?.McpServers,
config?.CustomAgents,
config?.SkillDirectories,
config?.DisabledSkills);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"session.resume", [request], cancellationToken);
var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
session.RegisterTools(config?.Tools ?? []);
if (config?.OnPermissionRequest != null)
{
session.RegisterPermissionHandler(config.OnPermissionRequest);
}
// Replace any existing session entry to ensure new config (like permission handler) is used
_sessions[response.SessionId] = session;
return session;
}
///
/// Gets the current connection state of the client.
///
///
/// The current : Disconnected, Connecting, Connected, or Error.
///
///
///
/// if (client.State == ConnectionState.Connected)
/// {
/// var session = await client.CreateSessionAsync();
/// }
///
///
public ConnectionState State
{
get
{
if (_connectionTask == null) return ConnectionState.Disconnected;
if (_connectionTask.IsFaulted) return ConnectionState.Error;
if (!_connectionTask.IsCompleted) return ConnectionState.Connecting;
return ConnectionState.Connected;
}
}
///
/// 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 connection.Rpc.InvokeWithCancellationAsync(
"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 connection.Rpc.InvokeWithCancellationAsync(
"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 connection.Rpc.InvokeWithCancellationAsync(
"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.
/// Thrown when the client is not connected or not authenticated.
public async Task> ListModelsAsync(CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"models.list", [], cancellationToken);
return response.Models;
}
///
/// 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);
/// }
///
///
public async Task GetLastSessionIdAsync(CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"session.getLastId", [], cancellationToken);
return response.SessionId;
}
///
/// Deletes a Copilot session by its ID.
///
/// 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.
///
/// This permanently removes the session and all its conversation history.
/// The session cannot be resumed after deletion.
///
///
///
/// await client.DeleteSessionAsync("session-123");
///
///
public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"session.delete", [new DeleteSessionRequest(sessionId)], cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException($"Failed to delete session {sessionId}: {response.Error}");
}
_sessions.TryRemove(sessionId, out _);
}
///
/// Lists all sessions known to the Copilot server.
///
/// 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(CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);
var response = await connection.Rpc.InvokeWithCancellationAsync(
"session.list", [], cancellationToken);
return response.Sessions;
}
private Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_connectionTask is null && !_options.AutoStart)
{
throw new InvalidOperationException($"Client not connected. Call {nameof(StartAsync)}() first.");
}
// If already started or starting, this will return the existing task
return (Task)StartAsync(cancellationToken);
}
private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
{
var expectedVersion = SdkProtocolVersion.GetVersion();
var pingResponse = await connection.Rpc.InvokeWithCancellationAsync(
"ping", [new PingRequest()], cancellationToken);
if (!pingResponse.ProtocolVersion.HasValue)
{
throw new InvalidOperationException(
$"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
$"but server does not report a protocol version. " +
$"Please update your server to ensure compatibility.");
}
if (pingResponse.ProtocolVersion.Value != expectedVersion)
{
throw new InvalidOperationException(
$"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
$"but server reports version {pingResponse.ProtocolVersion.Value}. " +
$"Please update your SDK or server to ensure compatibility.");
}
}
private static async Task<(Process Process, int? DetectedLocalhostTcpPort)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
{
var cliPath = options.CliPath ?? "copilot";
var args = new List();
if (options.CliArgs != null)
{
args.AddRange(options.CliArgs);
}
args.AddRange(["--server", "--log-level", options.LogLevel]);
if (options.UseStdio)
{
args.Add("--stdio");
}
else if (options.Port > 0)
{
args.AddRange(["--port", options.Port.ToString()]);
}
var (fileName, processArgs) = ResolveCliCommand(cliPath, args);
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)),
UseShellExecute = false,
RedirectStandardInput = options.UseStdio,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = options.Cwd,
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");
var cliProcess = new Process { StartInfo = startInfo };
cliProcess.Start();
// Forward stderr to logger
_ = Task.Run(async () =>
{
while (cliProcess != null && !cliProcess.HasExited)
{
var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken);
if (line != null)
{
logger.LogDebug("[CLI] {Line}", line);
}
}
}, cancellationToken);
var detectedLocalhostTcpPort = (int?)null;
if (!options.UseStdio)
{
// Wait for port announcement
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 == null) throw new Exception("CLI process exited unexpectedly");
var match = Regex.Match(line, @"listening on port (\d+)", RegexOptions.IgnoreCase);
if (match.Success)
{
detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value);
break;
}
}
}
return (cliProcess, detectedLocalhostTcpPort);
}
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));
}
// On Windows with UseShellExecute=false, Process.Start doesn't search PATHEXT,
// so use cmd /c to let the shell resolve the executable
if (OperatingSystem.IsWindows() && !Path.IsPathRooted(cliPath))
{
return ("cmd", new[] { "/c", cliPath }.Concat(args));
}
return (cliPath, args);
}
private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, CancellationToken cancellationToken)
{
Stream inputStream, outputStream;
TcpClient? tcpClient = null;
NetworkStream? networkStream = null;
if (_options.UseStdio)
{
if (cliProcess == null) throw new InvalidOperationException("CLI 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");
}
tcpClient = new();
await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken);
networkStream = tcpClient.GetStream();
inputStream = networkStream;
outputStream = networkStream;
}
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(
outputStream,
inputStream,
CreateSystemTextJsonFormatter()))
{
TraceSource = new LoggerTraceSource(_logger),
};
var handler = new RpcHandler(this);
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
rpc.StartListening();
return new Connection(rpc, cliProcess, tcpClient, networkStream);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]
private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() =>
new SystemTextJsonFormatter() { JsonSerializerOptions = SerializerOptionsForMessageFormatter };
private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions();
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.MakeReadOnly();
return options;
}
internal CopilotSession? GetSession(string sessionId) =>
_sessions.TryGetValue(sessionId, out var session) ? session : null;
///
/// Disposes the synchronously.
///
///
/// Prefer using for better performance in async contexts.
///
public void Dispose()
{
DisposeAsync().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 async Task OnToolCall(string sessionId,
string toolCallId,
string toolName,
object? arguments)
{
var session = client.GetSession(sessionId);
if (session == null)
{
throw new ArgumentException($"Unknown session {sessionId}");
}
if (session.GetTool(toolName) is not { } tool)
{
return new ToolCallResponse(new ToolResultObject
{
TextResultForLlm = $"Tool '{toolName}' is not supported.",
ResultType = "failure",
Error = $"tool '{toolName}' not supported"
});
}
try
{
var invocation = new ToolInvocation
{
SessionId = sessionId,
ToolCallId = toolCallId,
ToolName = toolName,
Arguments = arguments
};
// Map args from JSON into AIFunction format
var aiFunctionArgs = new AIFunctionArguments
{
Context = new Dictionary