Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
private readonly Dictionary<string, List<Action<SessionLifecycleEvent>>> _typedLifecycleHandlers = [];
private readonly object _lifecycleHandlersLock = new();
private readonly ConcurrentDictionary<string, CopilotSession> _shellProcessMap = new();
private ServerRpc? _rpc;

/// <summary>
Expand Down Expand Up @@ -423,6 +424,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
session.On(config.OnEvent);
}
_sessions[sessionId] = session;
session.SetShellProcessCallbacks(
(processId, s) => _shellProcessMap[processId] = s,
processId => _shellProcessMap.TryRemove(processId, out _));

try
{
Expand Down Expand Up @@ -527,6 +531,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
session.On(config.OnEvent);
}
_sessions[sessionId] = session;
session.SetShellProcessCallbacks(
(processId, s) => _shellProcessMap[processId] = s,
processId => _shellProcessMap.TryRemove(processId, out _));

try
{
Expand Down Expand Up @@ -1196,6 +1203,8 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
rpc.AddLocalRpcMethod("shell.output", handler.OnShellOutput);
rpc.AddLocalRpcMethod("shell.exit", handler.OnShellExit);
rpc.StartListening();

_rpc = new ServerRpc(rpc);
Expand Down Expand Up @@ -1404,6 +1413,34 @@ public async Task<PermissionRequestResponseV2> OnPermissionRequestV2(string sess
});
}
}

public void OnShellOutput(string processId, string stream, string data)
{
if (client._shellProcessMap.TryGetValue(processId, out var session))
{
session.DispatchShellOutput(new ShellOutputNotification
{
ProcessId = processId,
Stream = stream,
Data = data,
});
}
}

public void OnShellExit(string processId, int exitCode)
{
if (client._shellProcessMap.TryGetValue(processId, out var session))
{
session.DispatchShellExit(new ShellExitNotification
{
ProcessId = processId,
ExitCode = exitCode,
});
// Clean up the mapping after exit
client._shellProcessMap.TryRemove(processId, out _);
session.UntrackShellProcess(processId);
}
Comment on lines +1417 to +1442
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_shellProcessMap is the only routing mechanism for shell.output/shell.exit, but nothing in the .NET SDK calls CopilotSession.TrackShellProcess(...) (search shows only the method definition). This means these notifications will be dropped and OnShellOutput/OnShellExit won’t fire. The process registration needs to happen when starting a shell command (track immediately after receiving processId), or the server needs to include sessionId in these notifications so routing doesn’t depend on prior registration.

Copilot uses AI. Check for mistakes.
}
}

private class Connection(
Expand Down
160 changes: 122 additions & 38 deletions dotnet/src/Generated/Rpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ internal class PingRequest
public string? Message { get; set; }
}

/// <summary>RPC data type for ModelCapabilitiesSupports operations.</summary>
/// <summary>Feature flags indicating what the model supports.</summary>
public class ModelCapabilitiesSupports
{
/// <summary>Gets or sets the <c>vision</c> value.</summary>
/// <summary>Whether this model supports vision/image input.</summary>
[JsonPropertyName("vision")]
public bool? Vision { get; set; }

Expand All @@ -47,50 +47,50 @@ public class ModelCapabilitiesSupports
public bool? ReasoningEffort { get; set; }
}

/// <summary>RPC data type for ModelCapabilitiesLimits operations.</summary>
/// <summary>Token limits for prompts, outputs, and context window.</summary>
public class ModelCapabilitiesLimits
{
/// <summary>Gets or sets the <c>max_prompt_tokens</c> value.</summary>
/// <summary>Maximum number of prompt/input tokens.</summary>
[JsonPropertyName("max_prompt_tokens")]
public double? MaxPromptTokens { get; set; }

/// <summary>Gets or sets the <c>max_output_tokens</c> value.</summary>
/// <summary>Maximum number of output/completion tokens.</summary>
[JsonPropertyName("max_output_tokens")]
public double? MaxOutputTokens { get; set; }

/// <summary>Gets or sets the <c>max_context_window_tokens</c> value.</summary>
/// <summary>Maximum total context window size in tokens.</summary>
[JsonPropertyName("max_context_window_tokens")]
public double MaxContextWindowTokens { get; set; }
}

/// <summary>Model capabilities and limits.</summary>
public class ModelCapabilities
{
/// <summary>Gets or sets the <c>supports</c> value.</summary>
/// <summary>Feature flags indicating what the model supports.</summary>
[JsonPropertyName("supports")]
public ModelCapabilitiesSupports Supports { get => field ??= new(); set; }

/// <summary>Gets or sets the <c>limits</c> value.</summary>
/// <summary>Token limits for prompts, outputs, and context window.</summary>
[JsonPropertyName("limits")]
public ModelCapabilitiesLimits Limits { get => field ??= new(); set; }
}

/// <summary>Policy state (if applicable).</summary>
public class ModelPolicy
{
/// <summary>Gets or sets the <c>state</c> value.</summary>
/// <summary>Current policy state for this model.</summary>
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;

/// <summary>Gets or sets the <c>terms</c> value.</summary>
/// <summary>Usage terms or conditions for this model.</summary>
[JsonPropertyName("terms")]
public string Terms { get; set; } = string.Empty;
}

/// <summary>Billing information.</summary>
public class ModelBilling
{
/// <summary>Gets or sets the <c>multiplier</c> value.</summary>
/// <summary>Billing cost multiplier relative to the base rate.</summary>
[JsonPropertyName("multiplier")]
public double Multiplier { get; set; }
}
Expand Down Expand Up @@ -242,7 +242,7 @@ internal class SessionLogRequest
/// <summary>RPC data type for SessionModelGetCurrent operations.</summary>
public class SessionModelGetCurrentResult
{
/// <summary>Gets or sets the <c>modelId</c> value.</summary>
/// <summary>Currently active model identifier.</summary>
[JsonPropertyName("modelId")]
public string? ModelId { get; set; }
}
Expand All @@ -258,7 +258,7 @@ internal class SessionModelGetCurrentRequest
/// <summary>RPC data type for SessionModelSwitchTo operations.</summary>
public class SessionModelSwitchToResult
{
/// <summary>Gets or sets the <c>modelId</c> value.</summary>
/// <summary>Currently active model identifier after the switch.</summary>
[JsonPropertyName("modelId")]
public string? ModelId { get; set; }
}
Expand All @@ -270,13 +270,13 @@ internal class SessionModelSwitchToRequest
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = string.Empty;

/// <summary>Gets or sets the <c>modelId</c> value.</summary>
/// <summary>Model identifier to switch to.</summary>
[JsonPropertyName("modelId")]
public string ModelId { get; set; } = string.Empty;

/// <summary>Gets or sets the <c>reasoningEffort</c> value.</summary>
/// <summary>Reasoning effort level to use for the model.</summary>
[JsonPropertyName("reasoningEffort")]
public SessionModelSwitchToRequestReasoningEffort? ReasoningEffort { get; set; }
public string? ReasoningEffort { get; set; }
}

/// <summary>RPC data type for SessionModeGet operations.</summary>
Expand Down Expand Up @@ -586,7 +586,7 @@ internal class SessionCompactionCompactRequest
/// <summary>RPC data type for SessionToolsHandlePendingToolCall operations.</summary>
public class SessionToolsHandlePendingToolCallResult
{
/// <summary>Gets or sets the <c>success</c> value.</summary>
/// <summary>Whether the tool call result was handled successfully.</summary>
[JsonPropertyName("success")]
public bool Success { get; set; }
}
Expand Down Expand Up @@ -614,7 +614,7 @@ internal class SessionToolsHandlePendingToolCallRequest
/// <summary>RPC data type for SessionPermissionsHandlePendingPermissionRequest operations.</summary>
public class SessionPermissionsHandlePendingPermissionRequestResult
{
/// <summary>Gets or sets the <c>success</c> value.</summary>
/// <summary>Whether the permission request was handled successfully.</summary>
[JsonPropertyName("success")]
public bool Success { get; set; }
}
Expand All @@ -635,6 +635,58 @@ internal class SessionPermissionsHandlePendingPermissionRequestRequest
public object Result { get; set; } = null!;
}

/// <summary>RPC data type for SessionShellExec operations.</summary>
public class SessionShellExecResult
{
/// <summary>Unique identifier for tracking streamed output.</summary>
[JsonPropertyName("processId")]
public string ProcessId { get; set; } = string.Empty;
}

/// <summary>RPC data type for SessionShellExec operations.</summary>
internal class SessionShellExecRequest
{
/// <summary>Target session identifier.</summary>
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = string.Empty;

/// <summary>Shell command to execute.</summary>
[JsonPropertyName("command")]
public string Command { get; set; } = string.Empty;

/// <summary>Working directory (defaults to session working directory).</summary>
[JsonPropertyName("cwd")]
public string? Cwd { get; set; }

/// <summary>Timeout in milliseconds (default: 30000).</summary>
[JsonPropertyName("timeout")]
public double? Timeout { get; set; }
}

/// <summary>RPC data type for SessionShellKill operations.</summary>
public class SessionShellKillResult
{
/// <summary>Whether the signal was sent successfully.</summary>
[JsonPropertyName("killed")]
public bool Killed { get; set; }
}

/// <summary>RPC data type for SessionShellKill operations.</summary>
internal class SessionShellKillRequest
{
/// <summary>Target session identifier.</summary>
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = string.Empty;

/// <summary>Process identifier returned by shell.exec.</summary>
[JsonPropertyName("processId")]
public string ProcessId { get; set; } = string.Empty;

/// <summary>Signal to send (default: SIGTERM).</summary>
[JsonPropertyName("signal")]
public SessionShellKillRequestSignal? Signal { get; set; }
}

/// <summary>Log severity level. Determines how the message is displayed in the timeline. Defaults to "info".</summary>
[JsonConverter(typeof(JsonStringEnumConverter<SessionLogRequestLevel>))]
public enum SessionLogRequestLevel
Expand All @@ -651,25 +703,6 @@ public enum SessionLogRequestLevel
}


/// <summary>Defines the allowed values.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<SessionModelSwitchToRequestReasoningEffort>))]
public enum SessionModelSwitchToRequestReasoningEffort
{
/// <summary>The <c>low</c> variant.</summary>
[JsonStringEnumMemberName("low")]
Low,
/// <summary>The <c>medium</c> variant.</summary>
[JsonStringEnumMemberName("medium")]
Medium,
/// <summary>The <c>high</c> variant.</summary>
[JsonStringEnumMemberName("high")]
High,
/// <summary>The <c>xhigh</c> variant.</summary>
[JsonStringEnumMemberName("xhigh")]
Xhigh,
}


/// <summary>The current agent mode.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<SessionModeGetResultMode>))]
public enum SessionModeGetResultMode
Expand All @@ -686,6 +719,22 @@ public enum SessionModeGetResultMode
}


/// <summary>Signal to send (default: SIGTERM).</summary>
[JsonConverter(typeof(JsonStringEnumConverter<SessionShellKillRequestSignal>))]
public enum SessionShellKillRequestSignal
{
/// <summary>The <c>SIGTERM</c> variant.</summary>
[JsonStringEnumMemberName("SIGTERM")]
SIGTERM,
/// <summary>The <c>SIGKILL</c> variant.</summary>
[JsonStringEnumMemberName("SIGKILL")]
SIGKILL,
/// <summary>The <c>SIGINT</c> variant.</summary>
[JsonStringEnumMemberName("SIGINT")]
SIGINT,
}


/// <summary>Provides server-scoped RPC methods (no session required).</summary>
public class ServerRpc
{
Expand Down Expand Up @@ -787,6 +836,7 @@ internal SessionRpc(JsonRpc rpc, string sessionId)
Compaction = new CompactionApi(rpc, sessionId);
Tools = new ToolsApi(rpc, sessionId);
Permissions = new PermissionsApi(rpc, sessionId);
Shell = new ShellApi(rpc, sessionId);
}

/// <summary>Model APIs.</summary>
Expand Down Expand Up @@ -816,6 +866,9 @@ internal SessionRpc(JsonRpc rpc, string sessionId)
/// <summary>Permissions APIs.</summary>
public PermissionsApi Permissions { get; }

/// <summary>Shell APIs.</summary>
public ShellApi Shell { get; }

/// <summary>Calls "session.log".</summary>
public async Task<SessionLogResult> LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -844,7 +897,7 @@ public async Task<SessionModelGetCurrentResult> GetCurrentAsync(CancellationToke
}

/// <summary>Calls "session.model.switchTo".</summary>
public async Task<SessionModelSwitchToResult> SwitchToAsync(string modelId, SessionModelSwitchToRequestReasoningEffort? reasoningEffort = null, CancellationToken cancellationToken = default)
public async Task<SessionModelSwitchToResult> SwitchToAsync(string modelId, string? reasoningEffort = null, CancellationToken cancellationToken = default)
{
var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId, ReasoningEffort = reasoningEffort };
return await CopilotClient.InvokeRpcAsync<SessionModelSwitchToResult>(_rpc, "session.model.switchTo", [request], cancellationToken);
Expand Down Expand Up @@ -1067,6 +1120,33 @@ public async Task<SessionPermissionsHandlePendingPermissionRequestResult> Handle
}
}

/// <summary>Provides session-scoped Shell APIs.</summary>
public class ShellApi
{
private readonly JsonRpc _rpc;
private readonly string _sessionId;

internal ShellApi(JsonRpc rpc, string sessionId)
{
_rpc = rpc;
_sessionId = sessionId;
}

/// <summary>Calls "session.shell.exec".</summary>
public async Task<SessionShellExecResult> ExecAsync(string command, string? cwd = null, double? timeout = null, CancellationToken cancellationToken = default)
{
var request = new SessionShellExecRequest { SessionId = _sessionId, Command = command, Cwd = cwd, Timeout = timeout };
return await CopilotClient.InvokeRpcAsync<SessionShellExecResult>(_rpc, "session.shell.exec", [request], cancellationToken);
}

/// <summary>Calls "session.shell.kill".</summary>
public async Task<SessionShellKillResult> KillAsync(string processId, SessionShellKillRequestSignal? signal = null, CancellationToken cancellationToken = default)
{
var request = new SessionShellKillRequest { SessionId = _sessionId, ProcessId = processId, Signal = signal };
return await CopilotClient.InvokeRpcAsync<SessionShellKillResult>(_rpc, "session.shell.kill", [request], cancellationToken);
}
}

[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
AllowOutOfOrderMetadataProperties = true,
Expand Down Expand Up @@ -1115,6 +1195,10 @@ public async Task<SessionPermissionsHandlePendingPermissionRequestResult> Handle
[JsonSerializable(typeof(SessionPlanReadResult))]
[JsonSerializable(typeof(SessionPlanUpdateRequest))]
[JsonSerializable(typeof(SessionPlanUpdateResult))]
[JsonSerializable(typeof(SessionShellExecRequest))]
[JsonSerializable(typeof(SessionShellExecResult))]
[JsonSerializable(typeof(SessionShellKillRequest))]
[JsonSerializable(typeof(SessionShellKillResult))]
[JsonSerializable(typeof(SessionToolsHandlePendingToolCallRequest))]
[JsonSerializable(typeof(SessionToolsHandlePendingToolCallResult))]
[JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))]
Expand Down
Loading
Loading