Skip to content

Commit 430d816

Browse files
jmoseleyCopilot
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 062b61c commit 430d816

File tree

15 files changed

+1074
-6
lines changed

15 files changed

+1074
-6
lines changed

dotnet/src/Client.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7474
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
7575
private readonly Dictionary<string, List<Action<SessionLifecycleEvent>>> _typedLifecycleHandlers = [];
7676
private readonly object _lifecycleHandlersLock = new();
77+
private readonly ConcurrentDictionary<string, CopilotSession> _shellProcessMap = new();
7778
private ServerRpc? _rpc;
7879

7980
/// <summary>
@@ -423,6 +424,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
423424
session.On(config.OnEvent);
424425
}
425426
_sessions[sessionId] = session;
427+
session.SetShellProcessCallbacks(
428+
(processId, s) => _shellProcessMap[processId] = s,
429+
processId => _shellProcessMap.TryRemove(processId, out _));
426430

427431
try
428432
{
@@ -527,6 +531,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
527531
session.On(config.OnEvent);
528532
}
529533
_sessions[sessionId] = session;
534+
session.SetShellProcessCallbacks(
535+
(processId, s) => _shellProcessMap[processId] = s,
536+
processId => _shellProcessMap.TryRemove(processId, out _));
530537

531538
try
532539
{
@@ -1196,6 +1203,8 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
11961203
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
11971204
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
11981205
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1206+
rpc.AddLocalRpcMethod("shell.output", handler.OnShellOutput);
1207+
rpc.AddLocalRpcMethod("shell.exit", handler.OnShellExit);
11991208
rpc.StartListening();
12001209

12011210
_rpc = new ServerRpc(rpc);
@@ -1404,6 +1413,34 @@ public async Task<PermissionRequestResponseV2> OnPermissionRequestV2(string sess
14041413
});
14051414
}
14061415
}
1416+
1417+
public void OnShellOutput(string processId, string stream, string data)
1418+
{
1419+
if (client._shellProcessMap.TryGetValue(processId, out var session))
1420+
{
1421+
session.DispatchShellOutput(new ShellOutputNotification
1422+
{
1423+
ProcessId = processId,
1424+
Stream = stream,
1425+
Data = data,
1426+
});
1427+
}
1428+
}
1429+
1430+
public void OnShellExit(string processId, int exitCode)
1431+
{
1432+
if (client._shellProcessMap.TryGetValue(processId, out var session))
1433+
{
1434+
session.DispatchShellExit(new ShellExitNotification
1435+
{
1436+
ProcessId = processId,
1437+
ExitCode = exitCode,
1438+
});
1439+
// Clean up the mapping after exit
1440+
client._shellProcessMap.TryRemove(processId, out _);
1441+
session.UntrackShellProcess(processId);
1442+
}
1443+
}
14071444
}
14081445

14091446
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
/// Gets the unique identifier for this session.
@@ -263,6 +269,52 @@ public IDisposable On(SessionEventHandler handler)
263269
return new ActionDisposable(() => EventHandlers -= handler);
264270
}
265271

272+
/// <summary>
273+
/// Subscribes to shell output notifications for this session.
274+
/// </summary>
275+
/// <param name="handler">A callback that receives shell output notifications.</param>
276+
/// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns>
277+
/// <remarks>
278+
/// Shell output notifications are streamed in chunks when commands started
279+
/// via <c>session.Rpc.Shell.ExecAsync()</c> produce stdout or stderr output.
280+
/// </remarks>
281+
/// <example>
282+
/// <code>
283+
/// using var sub = session.OnShellOutput(n =>
284+
/// {
285+
/// Console.WriteLine($"[{n.ProcessId}:{n.Stream}] {n.Data}");
286+
/// });
287+
/// </code>
288+
/// </example>
289+
public IDisposable OnShellOutput(Action<ShellOutputNotification> handler)
290+
{
291+
ShellOutputHandlers += handler;
292+
return new ActionDisposable(() => ShellOutputHandlers -= handler);
293+
}
294+
295+
/// <summary>
296+
/// Subscribes to shell exit notifications for this session.
297+
/// </summary>
298+
/// <param name="handler">A callback that receives shell exit notifications.</param>
299+
/// <returns>An <see cref="IDisposable"/> that unsubscribes the handler when disposed.</returns>
300+
/// <remarks>
301+
/// Shell exit notifications are sent when commands started via
302+
/// <c>session.Rpc.Shell.ExecAsync()</c> complete (after all output has been streamed).
303+
/// </remarks>
304+
/// <example>
305+
/// <code>
306+
/// using var sub = session.OnShellExit(n =>
307+
/// {
308+
/// Console.WriteLine($"Process {n.ProcessId} exited with code {n.ExitCode}");
309+
/// });
310+
/// </code>
311+
/// </example>
312+
public IDisposable OnShellExit(Action<ShellExitNotification> handler)
313+
{
314+
ShellExitHandlers += handler;
315+
return new ActionDisposable(() => ShellExitHandlers -= handler);
316+
}
317+
266318
/// <summary>
267319
/// Dispatches an event to all registered handlers.
268320
/// </summary>
@@ -282,6 +334,57 @@ internal void DispatchEvent(SessionEvent sessionEvent)
282334
EventHandlers?.Invoke(sessionEvent);
283335
}
284336

337+
/// <summary>
338+
/// Dispatches a shell output notification to all registered handlers.
339+
/// </summary>
340+
internal void DispatchShellOutput(ShellOutputNotification notification)
341+
{
342+
ShellOutputHandlers?.Invoke(notification);
343+
}
344+
345+
/// <summary>
346+
/// Dispatches a shell exit notification to all registered handlers.
347+
/// </summary>
348+
internal void DispatchShellExit(ShellExitNotification notification)
349+
{
350+
ShellExitHandlers?.Invoke(notification);
351+
}
352+
353+
/// <summary>
354+
/// Track a shell process ID so notifications are routed to this session.
355+
/// </summary>
356+
internal void TrackShellProcess(string processId)
357+
{
358+
lock (_trackedProcessIdsLock)
359+
{
360+
_trackedProcessIds.Add(processId);
361+
}
362+
_registerShellProcess?.Invoke(processId, this);
363+
}
364+
365+
/// <summary>
366+
/// Stop tracking a shell process ID.
367+
/// </summary>
368+
internal void UntrackShellProcess(string processId)
369+
{
370+
lock (_trackedProcessIdsLock)
371+
{
372+
_trackedProcessIds.Remove(processId);
373+
}
374+
_unregisterShellProcess?.Invoke(processId);
375+
}
376+
377+
/// <summary>
378+
/// Set the registration callbacks for shell process tracking.
379+
/// </summary>
380+
internal void SetShellProcessCallbacks(
381+
Action<string, CopilotSession> register,
382+
Action<string> unregister)
383+
{
384+
_registerShellProcess = register;
385+
_unregisterShellProcess = unregister;
386+
}
387+
285388
/// <summary>
286389
/// Registers custom tool handlers for this session.
287390
/// </summary>
@@ -746,6 +849,18 @@ await InvokeRpcAsync<object>(
746849
}
747850

748851
EventHandlers = null;
852+
ShellOutputHandlers = null;
853+
ShellExitHandlers = null;
854+
855+
lock (_trackedProcessIdsLock)
856+
{
857+
foreach (var processId in _trackedProcessIds)
858+
{
859+
_unregisterShellProcess?.Invoke(processId);
860+
}
861+
_trackedProcessIds.Clear();
862+
}
863+
749864
_toolHandlers.Clear();
750865

751866
_permissionHandler = null;

dotnet/src/Types.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,54 @@ public class SessionLifecycleEvent
19361936
public SessionLifecycleEventMetadata? Metadata { get; set; }
19371937
}
19381938

1939+
// ============================================================================
1940+
// Shell Notification Types
1941+
// ============================================================================
1942+
1943+
/// <summary>
1944+
/// Notification sent when a shell command produces output.
1945+
/// Streamed in chunks (up to 64KB per notification).
1946+
/// </summary>
1947+
public class ShellOutputNotification
1948+
{
1949+
/// <summary>
1950+
/// Process identifier returned by shell.exec.
1951+
/// </summary>
1952+
[JsonPropertyName("processId")]
1953+
public string ProcessId { get; set; } = string.Empty;
1954+
1955+
/// <summary>
1956+
/// Which output stream produced this chunk ("stdout" or "stderr").
1957+
/// </summary>
1958+
[JsonPropertyName("stream")]
1959+
public string Stream { get; set; } = string.Empty;
1960+
1961+
/// <summary>
1962+
/// The output data (UTF-8 string).
1963+
/// </summary>
1964+
[JsonPropertyName("data")]
1965+
public string Data { get; set; } = string.Empty;
1966+
}
1967+
1968+
/// <summary>
1969+
/// Notification sent when a shell command exits.
1970+
/// Sent after all output has been streamed.
1971+
/// </summary>
1972+
public class ShellExitNotification
1973+
{
1974+
/// <summary>
1975+
/// Process identifier returned by shell.exec.
1976+
/// </summary>
1977+
[JsonPropertyName("processId")]
1978+
public string ProcessId { get; set; } = string.Empty;
1979+
1980+
/// <summary>
1981+
/// Process exit code (0 = success).
1982+
/// </summary>
1983+
[JsonPropertyName("exitCode")]
1984+
public int ExitCode { get; set; }
1985+
}
1986+
19391987
/// <summary>
19401988
/// Response from session.getForeground
19411989
/// </summary>
@@ -2000,6 +2048,8 @@ public class SetForegroundSessionResponse
20002048
[JsonSerializable(typeof(SessionContext))]
20012049
[JsonSerializable(typeof(SessionLifecycleEvent))]
20022050
[JsonSerializable(typeof(SessionLifecycleEventMetadata))]
2051+
[JsonSerializable(typeof(ShellExitNotification))]
2052+
[JsonSerializable(typeof(ShellOutputNotification))]
20032053
[JsonSerializable(typeof(SessionListFilter))]
20042054
[JsonSerializable(typeof(SessionMetadata))]
20052055
[JsonSerializable(typeof(SetForegroundSessionResponse))]

go/client.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ type Client struct {
8989
lifecycleHandlers []SessionLifecycleHandler
9090
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
9191
lifecycleHandlersMux sync.Mutex
92+
shellProcessMap map[string]*Session
93+
shellProcessMapMux sync.Mutex
9294
startStopMux sync.RWMutex // protects process and state during start/[force]stop
9395
processDone chan struct{}
9496
processErrorPtr *error
@@ -128,6 +130,7 @@ func NewClient(options *ClientOptions) *Client {
128130
options: opts,
129131
state: StateDisconnected,
130132
sessions: make(map[string]*Session),
133+
shellProcessMap: make(map[string]*Session),
131134
actualHost: "localhost",
132135
isExternalServer: false,
133136
useStdio: true,
@@ -537,6 +540,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
537540
// Create and register the session before issuing the RPC so that
538541
// events emitted by the CLI (e.g. session.start) are not dropped.
539542
session := newSession(sessionID, c.client, "")
543+
session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess)
540544

541545
session.registerTools(config.Tools)
542546
session.registerPermissionHandler(config.OnPermissionRequest)
@@ -650,6 +654,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
650654
// Create and register the session before issuing the RPC so that
651655
// events emitted by the CLI (e.g. session.start) are not dropped.
652656
session := newSession(sessionID, c.client, "")
657+
session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess)
653658

654659
session.registerTools(config.Tools)
655660
session.registerPermissionHandler(config.OnPermissionRequest)
@@ -1365,6 +1370,8 @@ func (c *Client) setupNotificationHandler() {
13651370
c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2))
13661371
c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest))
13671372
c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke))
1373+
c.client.SetRequestHandler("shell.output", jsonrpc2.NotificationHandlerFor(c.handleShellOutput))
1374+
c.client.SetRequestHandler("shell.exit", jsonrpc2.NotificationHandlerFor(c.handleShellExit))
13681375
}
13691376

13701377
func (c *Client) handleSessionEvent(req sessionEventRequest) {
@@ -1381,6 +1388,43 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) {
13811388
}
13821389
}
13831390

1391+
func (c *Client) handleShellOutput(notification ShellOutputNotification) {
1392+
c.shellProcessMapMux.Lock()
1393+
session, ok := c.shellProcessMap[notification.ProcessID]
1394+
c.shellProcessMapMux.Unlock()
1395+
1396+
if ok {
1397+
session.dispatchShellOutput(notification)
1398+
}
1399+
}
1400+
1401+
func (c *Client) handleShellExit(notification ShellExitNotification) {
1402+
c.shellProcessMapMux.Lock()
1403+
session, ok := c.shellProcessMap[notification.ProcessID]
1404+
c.shellProcessMapMux.Unlock()
1405+
1406+
if ok {
1407+
session.dispatchShellExit(notification)
1408+
// Clean up the mapping after exit
1409+
c.shellProcessMapMux.Lock()
1410+
delete(c.shellProcessMap, notification.ProcessID)
1411+
c.shellProcessMapMux.Unlock()
1412+
session.untrackShellProcess(notification.ProcessID)
1413+
}
1414+
}
1415+
1416+
func (c *Client) registerShellProcess(processID string, session *Session) {
1417+
c.shellProcessMapMux.Lock()
1418+
c.shellProcessMap[processID] = session
1419+
c.shellProcessMapMux.Unlock()
1420+
}
1421+
1422+
func (c *Client) unregisterShellProcess(processID string) {
1423+
c.shellProcessMapMux.Lock()
1424+
delete(c.shellProcessMap, processID)
1425+
c.shellProcessMapMux.Unlock()
1426+
}
1427+
13841428
// handleUserInputRequest handles a user input request from the CLI server.
13851429
func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) {
13861430
if req.SessionID == "" || req.Question == "" {

0 commit comments

Comments
 (0)