/*---------------------------------------------------------------------------------------------
* 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