/*---------------------------------------------------------------------------------------------
* 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.Diagnostics.CodeAnalysis;
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;
private static readonly TimeSpan s_stderrPumpShutdownTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_runtimeShutdownTimeout = TimeSpan.FromSeconds(10);
///
/// 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 readonly int? _optionsPort;
private readonly string? _optionsHost;
private readonly Func>>? _onListModels;
private readonly List _lifecycleHandlers = [];
private Task? _connectionTask;
private FfiRuntimeHost? _ffiHost;
private bool _disposed;
private int? _actualPort;
private int? _negotiatedProtocolVersion;
private SemaphoreSlim? _modelsCacheLock;
private List? _modelsCache;
private ServerRpc? _serverRpc;
///
/// Client-global RPC handlers (e.g. the LLM inference provider adapter),
/// built once at construction when the corresponding option is configured and
/// registered on every connection. Null when no client-global API is enabled.
///
private readonly ClientGlobalApiHandlers? _clientGlobalApis;
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 ?? ResolveDefaultConnection(_options);
switch (_connection)
{
case StdioRuntimeConnection:
break;
case InProcessRuntimeConnection:
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));
}
ValidateEnvironmentOptions(_options, _connection);
_logger = _options.Logger ?? NullLogger.Instance;
_onListModels = _options.OnListModels;
_clientGlobalApis = BuildClientGlobalApis();
// Empty mode: validate at construction time that the app supplied a
// per-session persistence location. The runtime is mode-agnostic, so
// without this check it would silently fall back to ~/.copilot, which
// defeats the point of empty mode for multi-tenant scenarios.
if (_options.Mode == CopilotClientMode.Empty)
{
var hasPersistence =
!string.IsNullOrEmpty(_options.BaseDirectory) ||
_options.SessionFs is not null ||
// External runtimes manage their own persistence layer; the SDK
// can't enforce it from here.
_connection is UriRuntimeConnection;
if (!hasPersistence)
{
throw new ArgumentException(
"CopilotClient was created with Mode = CopilotClientMode.Empty but neither " +
"BaseDirectory nor SessionFs was set. Empty mode requires an explicit " +
"per-session persistence location; pick one.",
nameof(options));
}
}
}
///
/// Validates environment-variable options against the resolved transport.
/// Per-client environment is only representable for child-process transports
/// (each client owns its own OS process). The in-process (FFI) transport
/// loads the native runtime into the shared host process, whose single
/// environment block cannot carry per-client values, so environment and
/// telemetry options that lower to environment variables are rejected there.
///
private static void ValidateEnvironmentOptions(CopilotClientOptions options, RuntimeConnection connection)
{
if (connection is InProcessRuntimeConnection)
{
if (options.Environment is not null)
{
throw new ArgumentException(
$"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} is not supported with " +
$"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport " +
"loads the native runtime into the shared host process, whose single environment block cannot carry " +
"per-client values. Set the variables on the host process environment instead.",
nameof(options));
}
if (options.Telemetry is not null)
{
throw new ArgumentException(
$"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Telemetry)} is not supported with " +
$"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): telemetry configuration is " +
"lowered to environment variables read by native runtime code running in the shared host process, so " +
"per-client telemetry cannot be honored in-process. Configure telemetry via the host process " +
"environment, or use a child-process transport.",
nameof(options));
}
return;
}
if (connection is ChildProcessRuntimeConnection { Environment: not null } && options.Environment is not null)
{
throw new ArgumentException(
$"Set environment variables via either {nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} " +
$"or {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)}, not both. " +
$"Prefer {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)} for " +
"child-process transports.",
nameof(options));
}
}
///
/// Environment variable that overrides the transport used when the caller does not
/// specify . Accepts "inprocess"
/// or "stdio" (case-insensitive); unset preserves the default stdio transport.
/// Any other value is an error. Ignored when a is set
/// explicitly.
///
internal const string DefaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION";
///
/// Resolves the default for the no-Connection case,
/// honoring .
///
private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions options)
{
var value = options.Environment is not null
&& options.Environment.TryGetValue(DefaultConnectionEnvVar, out var fromOptions)
? fromOptions
: Environment.GetEnvironmentVariable(DefaultConnectionEnvVar);
if (string.IsNullOrEmpty(value) || string.Equals(value, "stdio", StringComparison.OrdinalIgnoreCase))
{
return RuntimeConnection.ForStdio();
}
if (string.Equals(value, "inprocess", StringComparison.OrdinalIgnoreCase))
{
return RuntimeConnection.ForInProcess();
}
throw new ArgumentException(
$"Invalid {DefaultConnectionEnvVar} value '{value}'. Expected 'inprocess', 'stdio', or unset.");
}
///
/// 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;
ProcessStderrPump? stderrPump = null;
try
{
if (_connection is InProcessRuntimeConnection)
{
// In-process FFI hosting: load the Rust cdylib and let it spawn
// the CLI worker, instead of the SDK launching a CLI child process.
// The worker reads its configuration (telemetry export, etc.) from
// the environment passed here, so apply the same telemetry-derived
// vars the child-process path sets on its startInfo.Environment.
var ffiEnvironment = _options.Environment?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
?? new Dictionary();
ApplyTelemetryEnvironment(ffiEnvironment, _options.Telemetry);
var resolvedFfiEnvironment = ffiEnvironment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!);
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), resolvedFfiEnvironment, _logger);
_ffiHost = ffiHost;
await ffiHost.StartAsync(ct);
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
}
else 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, startedStderrPump) = await StartCliServerAsync(ct);
cliProcess = startedProcess;
stderrPump = startedStderrPump;
_actualPort = portOrNull;
connection = await ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrPump, 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);
}
await ConfigureLlmInferenceAsync(ct);
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, gracefulRuntimeShutdown: false);
}
else if (cliProcess is not null)
{
await CleanupCliProcessAsync(cliProcess, stderrPump, 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)
/// Requests runtime shutdown for SDK-owned CLI processes
/// 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, gracefulRuntimeShutdown: true);
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, gracefulRuntimeShutdown: false);
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, bool gracefulRuntimeShutdown)
{
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, gracefulRuntimeShutdown);
}
private async Task CleanupConnectionAsync(Connection ctx, List? errors, bool gracefulRuntimeShutdown)
{
if (gracefulRuntimeShutdown && (ctx.CliProcess is not null || ctx.FfiHost is not null))
{
var runtimeShutdownTimestamp = Stopwatch.GetTimestamp();
try
{
using var cancellation = new CancellationTokenSource(s_runtimeShutdownTimeout);
await ctx.Server.Runtime.ShutdownAsync(cancellation.Token);
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotClient.StopAsync runtime shutdown complete. Elapsed={Elapsed}",
runtimeShutdownTimestamp);
}
catch (Exception ex) when (ex is OperationCanceledException
or InvalidOperationException
or ObjectDisposedException
or IOException
or SocketException)
{
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, ex,
"CopilotClient.StopAsync runtime shutdown failed. Elapsed={Elapsed}",
runtimeShutdownTimestamp);
}
}
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, ctx.StderrPump, errors, _logger);
}
if (ctx.FfiHost is { } ffiHost)
{
try { ffiHost.Dispose(); }
catch (Exception ex) { AddCleanupError(errors, ex, _logger); }
_ffiHost = null;
}
}
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List? errors, ILogger? logger)
{
stderrPump?.Cancel();
try
{
if (!childProcess.HasExited)
{
// The runtime completes all cleanup before responding to
// runtime.shutdown and then leaves termination to us; it
// deliberately keeps its JSON-RPC server alive to send the
// response and never self-exits. Waiting for a self-exit that
// will never come just wastes time, so terminate the child
// immediately and only wait to reap it.
childProcess.Kill(entireProcessTree: true);
// Kill is asynchronous; wait for the root CLI process to exit so cleanup callers
// do not observe StopAsync/DisposeAsync completion while it is still tearing down.
var killWaitTimestamp = Stopwatch.GetTimestamp();
try
{
await childProcess.WaitForExitAsync().WaitAsync(s_runtimeShutdownTimeout);
}
catch (TimeoutException ex)
{
if (logger is not null)
{
LoggingHelpers.LogTiming(logger, LogLevel.Debug, ex,
"Timed out waiting for runtime process to exit after kill. Elapsed={Elapsed}, Timeout={Timeout}",
killWaitTimestamp,
s_runtimeShutdownTimeout);
}
AddCleanupError(errors, ex, logger);
}
}
}
catch (Exception ex)
{
AddCleanupError(errors, ex, logger);
}
if (stderrPump is not null)
{
var stderrPumpWaitTimestamp = Stopwatch.GetTimestamp();
try
{
await stderrPump.Completion.WaitAsync(s_stderrPumpShutdownTimeout);
}
catch (TimeoutException ex)
{
if (logger is not null)
{
LoggingHelpers.LogTiming(logger, LogLevel.Debug, ex,
"Timed out waiting for runtime stderr pump to stop. Elapsed={Elapsed}, Timeout={Timeout}",
stderrPumpWaitTimestamp,
s_stderrPumpShutdownTimeout);
}
AddCleanupError(errors, ex, logger);
}
catch (Exception ex)
{
AddCleanupError(errors, ex, logger);
}
}
try { 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);
}
Dictionary>>? callbacks = null;
Dictionary? wireSections = null;
if (systemMessage.Sections is { Count: > 0 })
{
wireSections ??= [];
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 is null)
{
return (systemMessage, null);
}
var wireConfig = new SystemMessageConfig
{
Mode = systemMessage.Mode,
Content = systemMessage.Content,
Sections = wireSections
};
return (wireConfig, callbacks);
}
///
/// Creates a , wires up handlers from the
/// session config, registers it with the client, and starts its event
/// processing loop. Used by both (invoked
/// from the JSON-RPC read loop the instant the response arrives, so that
/// session events delivered between the response and the awaiter
/// resuming are not dropped) and
/// (invoked before the RPC is issued, since the session id is known up
/// front).
///
private CopilotSession InitializeSession(
string sessionId,
JsonRpc rpc,
SessionConfigBase config,
Dictionary>>? transformCallbacks,
bool hasHooks,
string callerName)
{
var setupTimestamp = Stopwatch.GetTimestamp();
var session = new CopilotSession(
sessionId,
rpc,
_logger,
this);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
session.RegisterMcpAuthHandler(config.OnMcpAuthRequest);
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);
session.SetCanvasHandler(config.CanvasHandler);
session.RegisterBearerTokenProviders(BuildBearerTokenCallbacks(config));
RegisterSession(session);
session.StartProcessingEvents();
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
callerName + " local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}",
setupTimestamp,
sessionId,
config.Tools?.Count ?? 0,
config.Commands?.Count ?? 0,
hasHooks);
return session;
}
///
/// Implicit provider name for the singular, whole-session .
///
private const string DefaultBearerTokenProviderName = "default";
///
/// Collects the per-provider BearerTokenProvider callbacks keyed by
/// provider name for session-side registration. The singular, whole-session
/// uses the implicit
/// .
///
private static Dictionary>> BuildBearerTokenCallbacks(SessionConfigBase config)
{
var callbacks = new Dictionary>>(StringComparer.Ordinal);
if (config.Provider?.BearerTokenProvider is { } singular)
{
callbacks[DefaultBearerTokenProviderName] = singular;
}
if (config.Providers != null)
{
foreach (var provider in config.Providers.Where(provider => provider.BearerTokenProvider is not null))
{
callbacks[provider.Name] = provider.BearerTokenProvider!;
}
}
return callbacks;
}
///
/// Catches misuse of /
/// at the SDK boundary so
/// callers get an actionable error rather than a silently-empty filter.
/// The runtime treats a bare "*" as a literal name match for a tool
/// whose name is the single character *, which the runtime's
/// charset guard would reject at registration — so the filter effectively
/// matches nothing.
///
private static void ValidateToolFilterList(string field, IList? list)
{
if (list is null) return;
foreach (var entry in list)
{
if (entry == "*")
{
throw new ArgumentException(
$"Invalid {field} entry '*': there is no bare wildcard. " +
"Use `new ToolSet().AddBuiltIn(\"*\")`, `.AddMcp(\"*\")`, or " +
"`.AddCustom(\"*\")` to target a specific source.",
nameof(list));
}
}
}
///
/// Resolves /
/// for the wire payload,
/// validating empty-mode requirements. toolFilterPrecedence is
/// always excluded so SDK consumers get composable allowlist /
/// denylist semantics.
///
private (IList? AvailableTools, IList? ExcludedTools, OptionsUpdateToolFilterPrecedence ToolFilterPrecedence) ResolveToolFilterOptions(SessionConfigBase config)
{
ValidateToolFilterList(nameof(SessionConfigBase.AvailableTools), config.AvailableTools);
ValidateToolFilterList(nameof(SessionConfigBase.ExcludedTools), config.ExcludedTools);
if (_options.Mode == CopilotClientMode.Empty && config.AvailableTools is null)
{
throw new ArgumentException(
"CopilotClient is in Mode = CopilotClientMode.Empty but the session config did " +
"not specify AvailableTools. Empty mode requires every session to explicitly " +
"opt into the tools it wants — e.g. " +
"`AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated)`.",
nameof(config));
}
return (config.AvailableTools, config.ExcludedTools, OptionsUpdateToolFilterPrecedence.Excluded);
}
///
/// Applies mode-specific defaults to a session config in place. Caller
/// values win — only fields left unset by the caller are filled in.
///
private void ApplyConfigDefaultsForMode(SessionConfigBase config)
{
if (_options.Mode == CopilotClientMode.Empty)
{
config.EnableSessionTelemetry ??= false;
config.SkipEmbeddingRetrieval ??= true;
config.EmbeddingCacheStorage ??= EmbeddingCacheStorageMode.InMemory;
config.EnableOnDemandInstructionDiscovery ??= false;
config.EnableFileHooks ??= false;
config.EnableHostGitOperations ??= false;
config.EnableSessionStore ??= false;
config.EnableSkills ??= false;
config.Memory ??= new MemoryConfiguration { Enabled = false };
config.McpOAuthTokenStorage ??= McpOAuthTokenStorageMode.InMemory;
}
}
///
/// Returns the to send to the runtime,
/// adjusted for the current mode. In empty mode the
/// environment_context section is stripped unless the caller has
/// already taken control of it; append-mode messages are promoted to
/// customize so the env-context strip can apply alongside the caller's
/// content (the runtime appends
/// in both modes).
///
private SystemMessageConfig? GetSystemMessageConfigForMode(SystemMessageConfig? supplied)
{
if (_options.Mode != CopilotClientMode.Empty)
{
return supplied;
}
if (supplied is null)
{
return new SystemMessageConfig
{
Mode = SystemMessageMode.Customize,
Sections = new Dictionary
{
[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove },
},
};
}
switch (supplied.Mode)
{
case SystemMessageMode.Replace:
return supplied;
case SystemMessageMode.Customize:
if (supplied.Sections is not null && supplied.Sections.ContainsKey(SystemMessageSection.EnvironmentContext))
{
return supplied;
}
var mergedSections = supplied.Sections is null
? []
: new Dictionary(supplied.Sections);
mergedSections[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove };
return new SystemMessageConfig
{
Mode = SystemMessageMode.Customize,
Content = supplied.Content,
Sections = mergedSections,
};
case SystemMessageMode.Append:
case null:
// Promote to customize so we can also strip environment_context.
// The runtime appends Content to additional instructions in both
// customize and append modes, so the caller's text is preserved.
return new SystemMessageConfig
{
Mode = SystemMessageMode.Customize,
Content = supplied.Content,
Sections = new Dictionary
{
[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove },
},
};
default:
return supplied;
}
}
///
/// Applies the post-create / post-resume session.options.update
/// patch for the current mode. In empty mode this defaults the four
/// overridable feature flags to safe values (caller values from
/// win); installedPlugins=[] is
/// unconditional under empty mode so apps that need plugins must switch
/// modes. In copilot-cli mode only explicitly-set fields are forwarded.
///
private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, SessionConfigBase config, CancellationToken cancellationToken)
{
var hasAnyPatch = false;
bool? skipCustomInstructions = null;
bool? customAgentsLocalOnly = null;
bool? coauthorEnabled = null;
bool? manageScheduleEnabled = null;
IList? installedPlugins = null;
if (_options.Mode == CopilotClientMode.Empty)
{
skipCustomInstructions = config.SkipCustomInstructions ?? true;
customAgentsLocalOnly = config.CustomAgentsLocalOnly ?? true;
coauthorEnabled = config.CoauthorEnabled ?? false;
manageScheduleEnabled = config.ManageScheduleEnabled ?? false;
installedPlugins = [];
hasAnyPatch = true;
}
else
{
if (config.SkipCustomInstructions is not null) { skipCustomInstructions = config.SkipCustomInstructions; hasAnyPatch = true; }
if (config.CustomAgentsLocalOnly is not null) { customAgentsLocalOnly = config.CustomAgentsLocalOnly; hasAnyPatch = true; }
if (config.CoauthorEnabled is not null) { coauthorEnabled = config.CoauthorEnabled; hasAnyPatch = true; }
if (config.ManageScheduleEnabled is not null) { manageScheduleEnabled = config.ManageScheduleEnabled; hasAnyPatch = true; }
}
if (!hasAnyPatch) return;
try
{
#pragma warning disable GHCP001
await session.Rpc.Options.UpdateAsync(
skipCustomInstructions: skipCustomInstructions,
customAgentsLocalOnly: customAgentsLocalOnly,
coauthorEnabled: coauthorEnabled,
manageScheduleEnabled: manageScheduleEnabled,
installedPlugins: installedPlugins,
cancellationToken: cancellationToken).ConfigureAwait(false);
#pragma warning restore GHCP001
}
catch
{
// The runtime session exists but the post-create options
// patch failed — best-effort destroy so we don't leak it
// (in empty mode it would otherwise stay alive with
// permissive defaults).
try
{
await session.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Swallow: original error is what the caller needs.
}
throw;
}
}
///
/// 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();
ApplyConfigDefaultsForMode(config);
config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage);
var toolFilter = ResolveToolFilterOptions(config);
var hasHooks = config.Hooks != null && (
config.Hooks.OnPreToolUse != null ||
config.Hooks.OnPreMcpToolCall != null ||
config.Hooks.OnPostToolUse != null ||
config.Hooks.OnPostToolUseFailure != null ||
config.Hooks.OnUserPromptSubmitted != null ||
config.Hooks.OnSessionStart != null ||
config.Hooks.OnSessionEnd != null ||
config.Hooks.OnErrorOccurred != null);
var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage);
// For cloud sessions, let the CLI/server assign the session id and
// register the session lazily once the response arrives. For non-cloud
// sessions we generate the id client-side (when the caller didn't
// supply one) so the session can be registered BEFORE the RPC — the
// CLI may issue session-scoped requests (e.g. sessionFs.WriteFile
// for workspace metadata) during session.create processing, before
// it has sent the response.
var useServerGeneratedId = config.Cloud != null && string.IsNullOrEmpty(config.SessionId);
var localSessionId = useServerGeneratedId
? null
: (string.IsNullOrEmpty(config.SessionId) ? Guid.NewGuid().ToString() : config.SessionId);
CopilotSession? session = null;
if (localSessionId != null)
{
session = InitializeSession(
localSessionId,
connection.Rpc,
config,
transformCallbacks,
hasHooks,
"CopilotClient.CreateSessionAsync");
}
try
{
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
var request = new CreateSessionRequest(
config.Model,
localSessionId,
config.ClientName,
config.ReasoningEffort,
config.ReasoningSummary,
config.ContextTier,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.EnableCitations,
wireSystemMessage,
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
config.ExcludedBuiltInAgents,
config.Provider,
config.Capi,
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,
config.McpOAuthTokenStorage,
"direct",
config.CustomAgents,
config.DefaultAgent,
config.Agent,
config.ConfigDirectory,
config.EnableConfigDiscovery,
config.SkipEmbeddingRetrieval,
config.EmbeddingCacheStorage,
config.OrganizationCustomInstructions,
config.EnableOnDemandInstructionDiscovery,
config.EnableFileHooks,
config.EnableHostGitOperations,
config.EnableSessionStore,
config.EnableSkills,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions,
config.SessionLimits,
Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(),
RequestElicitation: config.OnElicitationRequest != null,
RequestMcpApps: config.EnableMcpApps ? true : null,
Traceparent: traceparent,
Tracestate: tracestate,
ModelCapabilities: config.ModelCapabilities,
GitHubToken: config.GitHubToken,
RemoteSession: config.RemoteSession,
Cloud: config.Cloud,
InstructionDirectories: config.InstructionDirectories,
PluginDirectories: config.PluginDirectories,
LargeOutput: config.LargeOutput,
Memory: config.Memory,
Canvases: config.Canvases,
RequestCanvasRenderer: config.RequestCanvasRenderer,
RequestExtensions: config.RequestExtensions,
ExtensionSdkPath: config.ExtensionSdkPath,
ExtensionInfo: config.ExtensionInfo,
Providers: config.Providers,
Models: config.Models,
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments,
EnableManagedSettings: config.EnableManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null);
var rpcTimestamp = Stopwatch.GetTimestamp();
// For the server-assigned (cloud) path, register the session
// synchronously from the read loop the instant the response
// arrives. This closes the small window where a session.event
// notification could arrive after the response but before the
// awaiter resumes — without this hook the dispatcher would
// silently drop those events. Non-cloud sessions are already
// registered above (before the RPC).
Action? onResponseInline = session != null ? null : raw =>
{
if (raw.ValueKind is JsonValueKind.Object
&& raw.TryGetProperty("sessionId", out var sessionIdProp)
&& sessionIdProp.ValueKind is JsonValueKind.String
&& sessionIdProp.GetString() is string sessionId
&& !string.IsNullOrEmpty(sessionId))
{
session = InitializeSession(
sessionId,
connection.Rpc,
config,
transformCallbacks,
hasHooks,
"CopilotClient.CreateSessionAsync");
}
};
var response = await InvokeRpcAsync(
connection.Rpc, "session.create", [request], null, cancellationToken, onResponseInline);
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotClient.CreateSessionAsync session creation request completed successfully. Elapsed={Elapsed}, SessionId={SessionId}",
rpcTimestamp,
response.SessionId);
if (session is null)
{
throw new InvalidOperationException("session.create response did not include a sessionId.");
}
if (localSessionId != null && !string.Equals(localSessionId, response.SessionId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"session.create returned sessionId {response.SessionId} but the caller requested {localSessionId}.");
}
if (config.OnMcpAuthRequest is not null)
{
await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken);
}
session.WorkspacePath = response.WorkspacePath;
session.SetCapabilities(response.Capabilities);
session.SetOpenCanvases(response.OpenCanvases);
await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
session?.RemoveFromClient();
if (ex is not OperationCanceledException)
{
LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex,
"CopilotClient.CreateSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}",
totalTimestamp,
session?.SessionId);
}
throw;
}
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotClient.CreateSessionAsync complete. Elapsed={Elapsed}, SessionId={SessionId}",
totalTimestamp,
session.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();
ApplyConfigDefaultsForMode(config);
config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage);
var toolFilter = ResolveToolFilterOptions(config);
var hasHooks = config.Hooks != null && (
config.Hooks.OnPreToolUse != null ||
config.Hooks.OnPreMcpToolCall != null ||
config.Hooks.OnPostToolUse != null ||
config.Hooks.OnPostToolUseFailure != 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 session = InitializeSession(
sessionId,
connection.Rpc,
config,
transformCallbacks,
hasHooks,
"CopilotClient.ResumeSessionAsync");
try
{
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
var request = new ResumeSessionRequest(
sessionId,
config.ClientName,
config.Model,
config.ReasoningEffort,
config.ReasoningSummary,
config.ContextTier,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.EnableCitations,
wireSystemMessage,
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
config.ExcludedBuiltInAgents,
config.Provider,
config.Capi,
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.ConfigDirectory,
config.EnableConfigDiscovery,
config.SkipEmbeddingRetrieval,
config.EmbeddingCacheStorage,
config.OrganizationCustomInstructions,
config.EnableOnDemandInstructionDiscovery,
config.EnableFileHooks,
config.EnableHostGitOperations,
config.EnableSessionStore,
config.EnableSkills,
config.SuppressResumeEvent is true ? true : null,
config.Streaming is true ? true : null,
config.IncludeSubAgentStreamingEvents,
config.McpServers,
config.McpOAuthTokenStorage,
"direct",
config.CustomAgents,
config.DefaultAgent,
config.Agent,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions,
config.SessionLimits,
Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(),
RequestElicitation: config.OnElicitationRequest != null,
RequestMcpApps: config.EnableMcpApps ? true : null,
Traceparent: traceparent,
Tracestate: tracestate,
ModelCapabilities: config.ModelCapabilities,
GitHubToken: config.GitHubToken,
RemoteSession: config.RemoteSession,
ContinuePendingWork: config.ContinuePendingWork,
InstructionDirectories: config.InstructionDirectories,
PluginDirectories: config.PluginDirectories,
LargeOutput: config.LargeOutput,
Memory: config.Memory,
Canvases: config.Canvases,
RequestCanvasRenderer: config.RequestCanvasRenderer,
RequestExtensions: config.RequestExtensions,
ExtensionSdkPath: config.ExtensionSdkPath,
ExtensionInfo: config.ExtensionInfo,
OpenCanvases: config.OpenCanvases,
Providers: config.Providers,
Models: config.Models,
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments,
EnableManagedSettings: config.EnableManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null);
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);
session.SetOpenCanvases(response.OpenCanvases);
if (config.OnMcpAuthRequest is not null)
{
await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken);
}
await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false);
}
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)
{
if (_modelsCacheLock is null)
{
Interlocked.CompareExchange(ref _modelsCacheLock, new(1, 1), null);
}
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 (_lifecycleHandlers)
{
_lifecycleHandlers.Add(subscription);
}
return new ActionDisposable(() =>
{
lock (_lifecycleHandlers)
{
_lifecycleHandlers.Remove(subscription);
}
});
}
private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
{
LifecycleSubscription[] snapshot;
lock (_lifecycleHandlers)
{
snapshot = _lifecycleHandlers.ToArray();
}
var eventType = evt.GetType();
foreach (var subscription in snapshot)
{
if (subscription.EventType.IsAssignableFrom(eventType))
{
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