Skip to content

Commit 0df45f8

Browse files
jmoseleyCopilot
authored andcommitted
Add shell notification types and handlers across all SDKs
Add support for shell.output and shell.exit JSON-RPC notifications that stream output from shell commands started via session.shell.exec. Each SDK gets: - ShellOutputNotification and ShellExitNotification types - Session-level onShellOutput/onShellExit subscription methods - Client-level processId-to-session routing with auto-cleanup on exit - Process tracking via _trackShellProcess/_untrackShellProcess The RPC method wrappers (session.rpc.shell.exec and session.rpc.shell.stop) will be auto-generated once the @github/copilot package is updated with the new api.schema.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 05dd60e commit 0df45f8

15 files changed

Lines changed: 1075 additions & 7 deletions

File tree

dotnet/src/Client.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7878
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
7979
private readonly Dictionary<string, List<Action<SessionLifecycleEvent>>> _typedLifecycleHandlers = [];
8080
private readonly object _lifecycleHandlersLock = new();
81+
private readonly ConcurrentDictionary<string, CopilotSession> _shellProcessMap = new();
8182
private ServerRpc? _rpc;
8283

8384
/// <summary>
@@ -428,6 +429,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
428429
session.On(config.OnEvent);
429430
}
430431
_sessions[sessionId] = session;
432+
session.SetShellProcessCallbacks(
433+
(processId, s) => _shellProcessMap[processId] = s,
434+
processId => _shellProcessMap.TryRemove(processId, out _));
431435

432436
try
433437
{
@@ -532,6 +536,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
532536
session.On(config.OnEvent);
533537
}
534538
_sessions[sessionId] = session;
539+
session.SetShellProcessCallbacks(
540+
(processId, s) => _shellProcessMap[processId] = s,
541+
processId => _shellProcessMap.TryRemove(processId, out _));
535542

536543
try
537544
{
@@ -1202,6 +1209,8 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
12021209
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
12031210
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
12041211
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1212+
rpc.AddLocalRpcMethod("shell.output", handler.OnShellOutput);
1213+
rpc.AddLocalRpcMethod("shell.exit", handler.OnShellExit);
12051214
rpc.StartListening();
12061215

12071216
// Transition state to Disconnected if the JSON-RPC connection drops
@@ -1421,6 +1430,34 @@ public async Task<PermissionRequestResponseV2> OnPermissionRequestV2(string sess
14211430
});
14221431
}
14231432
}
1433+
1434+
public void OnShellOutput(string processId, string stream, string data)
1435+
{
1436+
if (client._shellProcessMap.TryGetValue(processId, out var session))
1437+
{
1438+
session.DispatchShellOutput(new ShellOutputNotification
1439+
{
1440+
ProcessId = processId,
1441+
Stream = stream,
1442+
Data = data,
1443+
});
1444+
}
1445+
}
1446+
1447+
public void OnShellExit(string processId, int exitCode)
1448+
{
1449+
if (client._shellProcessMap.TryGetValue(processId, out var session))
1450+
{
1451+
session.DispatchShellExit(new ShellExitNotification
1452+
{
1453+
ProcessId = processId,
1454+
ExitCode = exitCode,
1455+
});
1456+
// Clean up the mapping after exit
1457+
client._shellProcessMap.TryRemove(processId, out _);
1458+
session.UntrackShellProcess(processId);
1459+
}
1460+
}
14241461
}
14251462

14261463
private class Connection(

dotnet/src/Session.cs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public sealed partial class CopilotSession : IAsyncDisposable
6767
private readonly SemaphoreSlim _hooksLock = new(1, 1);
6868
private SessionRpc? _sessionRpc;
6969
private int _isDisposed;
70+
private event Action<ShellOutputNotification>? ShellOutputHandlers;
71+
private event Action<ShellExitNotification>? ShellExitHandlers;
72+
private readonly HashSet<string> _trackedProcessIds = [];
73+
private readonly object _trackedProcessIdsLock = new();
74+
private Action<string, CopilotSession>? _registerShellProcess;
75+
private Action<string>? _unregisterShellProcess;
7076

7177
/// <summary>
7278
/// Channel that serializes event dispatch. <see cref="DispatchEvent"/> enqueues;
@@ -278,6 +284,52 @@ public IDisposable On(SessionEventHandler handler)
278284
return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(handler)));
279285
}
280286

287+
/// <summary>
288+
/// Subscribes to shell output notifications for this session.
289+
/// </summary>
290+
/// <param name="handler">A callback that receives shell output notifications.</param>
291+
/// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns>
292+
/// <remarks>
293+
/// Shell output notifications are streamed in chunks when commands started
294+
/// via <c>session.Rpc.Shell.ExecAsync()</c> produce stdout or stderr output.
295+
/// </remarks>
296+
/// <example>
297+
/// <code>
298+
/// using var sub = session.OnShellOutput(n =>
299+
/// {
300+
/// Console.WriteLine($"[{n.ProcessId}:{n.Stream}] {n.Data}");
301+
/// });
302+
/// </code>
303+
/// </example>
304+
public IDisposable OnShellOutput(Action<ShellOutputNotification> handler)
305+
{
306+
ShellOutputHandlers += handler;
307+
return new ActionDisposable(() => ShellOutputHandlers -= handler);
308+
}
309+
310+
/// <summary>
311+
/// Subscribes to shell exit notifications for this session.
312+
/// </summary>
313+
/// <param name="handler">A callback that receives shell exit notifications.</param>
314+
/// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns>
315+
/// <remarks>
316+
/// Shell exit notifications are sent when commands started via
317+
/// <c>session.Rpc.Shell.ExecAsync()</c> complete (after all output has been streamed).
318+
/// </remarks>
319+
/// <example>
320+
/// <code>
321+
/// using var sub = session.OnShellExit(n =>
322+
/// {
323+
/// Console.WriteLine($"Process {n.ProcessId} exited with code {n.ExitCode}");
324+
/// });
325+
/// </code>
326+
/// </example>
327+
public IDisposable OnShellExit(Action<ShellExitNotification> handler)
328+
{
329+
ShellExitHandlers += handler;
330+
return new ActionDisposable(() => ShellExitHandlers -= handler);
331+
}
332+
281333
/// <summary>
282334
/// Enqueues an event for serial dispatch to all registered handlers.
283335
/// </summary>
@@ -323,6 +375,57 @@ private async Task ProcessEventsAsync()
323375
}
324376
}
325377

378+
/// <summary>
379+
/// Dispatches a shell output notification to all registered handlers.
380+
/// </summary>
381+
internal void DispatchShellOutput(ShellOutputNotification notification)
382+
{
383+
ShellOutputHandlers?.Invoke(notification);
384+
}
385+
386+
/// <summary>
387+
/// Dispatches a shell exit notification to all registered handlers.
388+
/// </summary>
389+
internal void DispatchShellExit(ShellExitNotification notification)
390+
{
391+
ShellExitHandlers?.Invoke(notification);
392+
}
393+
394+
/// <summary>
395+
/// Track a shell process ID so notifications are routed to this session.
396+
/// </summary>
397+
internal void TrackShellProcess(string processId)
398+
{
399+
lock (_trackedProcessIdsLock)
400+
{
401+
_trackedProcessIds.Add(processId);
402+
}
403+
_registerShellProcess?.Invoke(processId, this);
404+
}
405+
406+
/// <summary>
407+
/// Stop tracking a shell process ID.
408+
/// </summary>
409+
internal void UntrackShellProcess(string processId)
410+
{
411+
lock (_trackedProcessIdsLock)
412+
{
413+
_trackedProcessIds.Remove(processId);
414+
}
415+
_unregisterShellProcess?.Invoke(processId);
416+
}
417+
418+
/// <summary>
419+
/// Set the registration callbacks for shell process tracking.
420+
/// </summary>
421+
internal void SetShellProcessCallbacks(
422+
Action<string, CopilotSession> register,
423+
Action<string> unregister)
424+
{
425+
_registerShellProcess = register;
426+
_unregisterShellProcess = unregister;
427+
}
428+
326429
/// <summary>
327430
/// Registers custom tool handlers for this session.
328431
/// </summary>
@@ -805,6 +908,18 @@ await InvokeRpcAsync<object>(
805908
}
806909

807910
_eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray<SessionEventHandler>.Empty);
911+
ShellOutputHandlers = null;
912+
ShellExitHandlers = null;
913+
914+
lock (_trackedProcessIdsLock)
915+
{
916+
foreach (var processId in _trackedProcessIds)
917+
{
918+
_unregisterShellProcess?.Invoke(processId);
919+
}
920+
_trackedProcessIds.Clear();
921+
}
922+
808923
_toolHandlers.Clear();
809924

810925
_permissionHandler = null;

dotnet/src/Types.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,6 +1943,54 @@ public class SessionLifecycleEvent
19431943
public SessionLifecycleEventMetadata? Metadata { get; set; }
19441944
}
19451945

1946+
// ============================================================================
1947+
// Shell Notification Types
1948+
// ============================================================================
1949+
1950+
/// <summary>
1951+
/// Notification sent when a shell command produces output.
1952+
/// Streamed in chunks (up to 64KB per notification).
1953+
/// </summary>
1954+
public class ShellOutputNotification
1955+
{
1956+
/// <summary>
1957+
/// Process identifier returned by shell.exec.
1958+
/// </summary>
1959+
[JsonPropertyName("processId")]
1960+
public string ProcessId { get; set; } = string.Empty;
1961+
1962+
/// <summary>
1963+
/// Which output stream produced this chunk ("stdout" or "stderr").
1964+
/// </summary>
1965+
[JsonPropertyName("stream")]
1966+
public string Stream { get; set; } = string.Empty;
1967+
1968+
/// <summary>
1969+
/// The output data (UTF-8 string).
1970+
/// </summary>
1971+
[JsonPropertyName("data")]
1972+
public string Data { get; set; } = string.Empty;
1973+
}
1974+
1975+
/// <summary>
1976+
/// Notification sent when a shell command exits.
1977+
/// Sent after all output has been streamed.
1978+
/// </summary>
1979+
public class ShellExitNotification
1980+
{
1981+
/// <summary>
1982+
/// Process identifier returned by shell.exec.
1983+
/// </summary>
1984+
[JsonPropertyName("processId")]
1985+
public string ProcessId { get; set; } = string.Empty;
1986+
1987+
/// <summary>
1988+
/// Process exit code (0 = success).
1989+
/// </summary>
1990+
[JsonPropertyName("exitCode")]
1991+
public int ExitCode { get; set; }
1992+
}
1993+
19461994
/// <summary>
19471995
/// Response from session.getForeground
19481996
/// </summary>
@@ -2007,6 +2055,8 @@ public class SetForegroundSessionResponse
20072055
[JsonSerializable(typeof(SessionContext))]
20082056
[JsonSerializable(typeof(SessionLifecycleEvent))]
20092057
[JsonSerializable(typeof(SessionLifecycleEventMetadata))]
2058+
[JsonSerializable(typeof(ShellExitNotification))]
2059+
[JsonSerializable(typeof(ShellOutputNotification))]
20102060
[JsonSerializable(typeof(SessionListFilter))]
20112061
[JsonSerializable(typeof(SessionMetadata))]
20122062
[JsonSerializable(typeof(SetForegroundSessionResponse))]

go/client.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ type Client struct {
9191
lifecycleHandlers []SessionLifecycleHandler
9292
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
9393
lifecycleHandlersMux sync.Mutex
94+
shellProcessMap map[string]*Session
95+
shellProcessMapMux sync.Mutex
9496
startStopMux sync.RWMutex // protects process and state during start/[force]stop
9597
processDone chan struct{}
9698
processErrorPtr *error
@@ -130,6 +132,7 @@ func NewClient(options *ClientOptions) *Client {
130132
options: opts,
131133
state: StateDisconnected,
132134
sessions: make(map[string]*Session),
135+
shellProcessMap: make(map[string]*Session),
133136
actualHost: "localhost",
134137
isExternalServer: false,
135138
useStdio: true,
@@ -535,6 +538,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
535538
// Create and register the session before issuing the RPC so that
536539
// events emitted by the CLI (e.g. session.start) are not dropped.
537540
session := newSession(sessionID, c.client, "")
541+
session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess)
538542

539543
session.registerTools(config.Tools)
540544
session.registerPermissionHandler(config.OnPermissionRequest)
@@ -648,6 +652,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
648652
// Create and register the session before issuing the RPC so that
649653
// events emitted by the CLI (e.g. session.start) are not dropped.
650654
session := newSession(sessionID, c.client, "")
655+
session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess)
651656

652657
session.registerTools(config.Tools)
653658
session.registerPermissionHandler(config.OnPermissionRequest)
@@ -1379,6 +1384,8 @@ func (c *Client) setupNotificationHandler() {
13791384
c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2))
13801385
c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest))
13811386
c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke))
1387+
c.client.SetRequestHandler("shell.output", jsonrpc2.NotificationHandlerFor(c.handleShellOutput))
1388+
c.client.SetRequestHandler("shell.exit", jsonrpc2.NotificationHandlerFor(c.handleShellExit))
13821389
}
13831390

13841391
func (c *Client) handleSessionEvent(req sessionEventRequest) {
@@ -1395,6 +1402,43 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) {
13951402
}
13961403
}
13971404

1405+
func (c *Client) handleShellOutput(notification ShellOutputNotification) {
1406+
c.shellProcessMapMux.Lock()
1407+
session, ok := c.shellProcessMap[notification.ProcessID]
1408+
c.shellProcessMapMux.Unlock()
1409+
1410+
if ok {
1411+
session.dispatchShellOutput(notification)
1412+
}
1413+
}
1414+
1415+
func (c *Client) handleShellExit(notification ShellExitNotification) {
1416+
c.shellProcessMapMux.Lock()
1417+
session, ok := c.shellProcessMap[notification.ProcessID]
1418+
c.shellProcessMapMux.Unlock()
1419+
1420+
if ok {
1421+
session.dispatchShellExit(notification)
1422+
// Clean up the mapping after exit
1423+
c.shellProcessMapMux.Lock()
1424+
delete(c.shellProcessMap, notification.ProcessID)
1425+
c.shellProcessMapMux.Unlock()
1426+
session.untrackShellProcess(notification.ProcessID)
1427+
}
1428+
}
1429+
1430+
func (c *Client) registerShellProcess(processID string, session *Session) {
1431+
c.shellProcessMapMux.Lock()
1432+
c.shellProcessMap[processID] = session
1433+
c.shellProcessMapMux.Unlock()
1434+
}
1435+
1436+
func (c *Client) unregisterShellProcess(processID string) {
1437+
c.shellProcessMapMux.Lock()
1438+
delete(c.shellProcessMap, processID)
1439+
c.shellProcessMapMux.Unlock()
1440+
}
1441+
13981442
// handleUserInputRequest handles a user input request from the CLI server.
13991443
func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) {
14001444
if req.SessionID == "" || req.Question == "" {

0 commit comments

Comments
 (0)