diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index fd56674b..e18ef4d2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -78,6 +78,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable private readonly List> _lifecycleHandlers = []; private readonly Dictionary>> _typedLifecycleHandlers = []; private readonly object _lifecycleHandlersLock = new(); + private readonly ConcurrentDictionary _shellProcessMap = new(); private ServerRpc? _rpc; /// @@ -428,6 +429,9 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.On(config.OnEvent); } _sessions[sessionId] = session; + session.SetShellProcessCallbacks( + (processId, s) => _shellProcessMap[processId] = s, + processId => _shellProcessMap.TryRemove(processId, out _)); try { @@ -532,6 +536,9 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.On(config.OnEvent); } _sessions[sessionId] = session; + session.SetShellProcessCallbacks( + (processId, s) => _shellProcessMap[processId] = s, + processId => _shellProcessMap.TryRemove(processId, out _)); try { @@ -1202,6 +1209,8 @@ private async Task 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(); // Transition state to Disconnected if the JSON-RPC connection drops @@ -1421,6 +1430,34 @@ public async Task 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); + } + } } private class Connection( diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 5f83ef6a..aeea8cad 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -67,6 +67,12 @@ public sealed partial class CopilotSession : IAsyncDisposable private readonly SemaphoreSlim _hooksLock = new(1, 1); private SessionRpc? _sessionRpc; private int _isDisposed; + private event Action? ShellOutputHandlers; + private event Action? ShellExitHandlers; + private readonly HashSet _trackedProcessIds = []; + private readonly object _trackedProcessIdsLock = new(); + private Action? _registerShellProcess; + private Action? _unregisterShellProcess; /// /// Channel that serializes event dispatch. enqueues; @@ -278,6 +284,52 @@ public IDisposable On(SessionEventHandler handler) return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(handler))); } + /// + /// Subscribes to shell output notifications for this session. + /// + /// A callback that receives shell output notifications. + /// An that unsubscribes the handler when disposed. + /// + /// Shell output notifications are streamed in chunks when commands started + /// via session.Rpc.Shell.ExecAsync() produce stdout or stderr output. + /// + /// + /// + /// using var sub = session.OnShellOutput(n => + /// { + /// Console.WriteLine($"[{n.ProcessId}:{n.Stream}] {n.Data}"); + /// }); + /// + /// + public IDisposable OnShellOutput(Action handler) + { + ShellOutputHandlers += handler; + return new ActionDisposable(() => ShellOutputHandlers -= handler); + } + + /// + /// Subscribes to shell exit notifications for this session. + /// + /// A callback that receives shell exit notifications. + /// An that unsubscribes the handler when disposed. + /// + /// Shell exit notifications are sent when commands started via + /// session.Rpc.Shell.ExecAsync() complete (after all output has been streamed). + /// + /// + /// + /// using var sub = session.OnShellExit(n => + /// { + /// Console.WriteLine($"Process {n.ProcessId} exited with code {n.ExitCode}"); + /// }); + /// + /// + public IDisposable OnShellExit(Action handler) + { + ShellExitHandlers += handler; + return new ActionDisposable(() => ShellExitHandlers -= handler); + } + /// /// Enqueues an event for serial dispatch to all registered handlers. /// @@ -323,6 +375,57 @@ private async Task ProcessEventsAsync() } } + /// + /// Dispatches a shell output notification to all registered handlers. + /// + internal void DispatchShellOutput(ShellOutputNotification notification) + { + ShellOutputHandlers?.Invoke(notification); + } + + /// + /// Dispatches a shell exit notification to all registered handlers. + /// + internal void DispatchShellExit(ShellExitNotification notification) + { + ShellExitHandlers?.Invoke(notification); + } + + /// + /// Track a shell process ID so notifications are routed to this session. + /// + internal void TrackShellProcess(string processId) + { + lock (_trackedProcessIdsLock) + { + _trackedProcessIds.Add(processId); + } + _registerShellProcess?.Invoke(processId, this); + } + + /// + /// Stop tracking a shell process ID. + /// + internal void UntrackShellProcess(string processId) + { + lock (_trackedProcessIdsLock) + { + _trackedProcessIds.Remove(processId); + } + _unregisterShellProcess?.Invoke(processId); + } + + /// + /// Set the registration callbacks for shell process tracking. + /// + internal void SetShellProcessCallbacks( + Action register, + Action unregister) + { + _registerShellProcess = register; + _unregisterShellProcess = unregister; + } + /// /// Registers custom tool handlers for this session. /// @@ -805,6 +908,18 @@ await InvokeRpcAsync( } _eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray.Empty); + ShellOutputHandlers = null; + ShellExitHandlers = null; + + lock (_trackedProcessIdsLock) + { + foreach (var processId in _trackedProcessIds) + { + _unregisterShellProcess?.Invoke(processId); + } + _trackedProcessIds.Clear(); + } + _toolHandlers.Clear(); _permissionHandler = null; diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index cdc08180..8142166a 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1943,6 +1943,54 @@ public class SessionLifecycleEvent public SessionLifecycleEventMetadata? Metadata { get; set; } } +// ============================================================================ +// Shell Notification Types +// ============================================================================ + +/// +/// Notification sent when a shell command produces output. +/// Streamed in chunks (up to 64KB per notification). +/// +public class ShellOutputNotification +{ + /// + /// Process identifier returned by shell.exec. + /// + [JsonPropertyName("processId")] + public string ProcessId { get; set; } = string.Empty; + + /// + /// Which output stream produced this chunk ("stdout" or "stderr"). + /// + [JsonPropertyName("stream")] + public string Stream { get; set; } = string.Empty; + + /// + /// The output data (UTF-8 string). + /// + [JsonPropertyName("data")] + public string Data { get; set; } = string.Empty; +} + +/// +/// Notification sent when a shell command exits. +/// Sent after all output has been streamed. +/// +public class ShellExitNotification +{ + /// + /// Process identifier returned by shell.exec. + /// + [JsonPropertyName("processId")] + public string ProcessId { get; set; } = string.Empty; + + /// + /// Process exit code (0 = success). + /// + [JsonPropertyName("exitCode")] + public int ExitCode { get; set; } +} + /// /// Response from session.getForeground /// @@ -2007,6 +2055,8 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(SessionContext))] [JsonSerializable(typeof(SessionLifecycleEvent))] [JsonSerializable(typeof(SessionLifecycleEventMetadata))] +[JsonSerializable(typeof(ShellExitNotification))] +[JsonSerializable(typeof(ShellOutputNotification))] [JsonSerializable(typeof(SessionListFilter))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionResponse))] diff --git a/go/client.go b/go/client.go index afd0e70c..5f8206c8 100644 --- a/go/client.go +++ b/go/client.go @@ -91,6 +91,8 @@ type Client struct { lifecycleHandlers []SessionLifecycleHandler typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler lifecycleHandlersMux sync.Mutex + shellProcessMap map[string]*Session + shellProcessMapMux sync.Mutex startStopMux sync.RWMutex // protects process and state during start/[force]stop processDone chan struct{} processErrorPtr *error @@ -130,6 +132,7 @@ func NewClient(options *ClientOptions) *Client { options: opts, state: StateDisconnected, sessions: make(map[string]*Session), + shellProcessMap: make(map[string]*Session), actualHost: "localhost", isExternalServer: false, useStdio: true, @@ -535,6 +538,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") + session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess) session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) @@ -648,6 +652,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") + session.setShellProcessCallbacks(c.registerShellProcess, c.unregisterShellProcess) session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) @@ -1379,6 +1384,8 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) + c.client.SetRequestHandler("shell.output", jsonrpc2.NotificationHandlerFor(c.handleShellOutput)) + c.client.SetRequestHandler("shell.exit", jsonrpc2.NotificationHandlerFor(c.handleShellExit)) } func (c *Client) handleSessionEvent(req sessionEventRequest) { @@ -1395,6 +1402,43 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) { } } +func (c *Client) handleShellOutput(notification ShellOutputNotification) { + c.shellProcessMapMux.Lock() + session, ok := c.shellProcessMap[notification.ProcessID] + c.shellProcessMapMux.Unlock() + + if ok { + session.dispatchShellOutput(notification) + } +} + +func (c *Client) handleShellExit(notification ShellExitNotification) { + c.shellProcessMapMux.Lock() + session, ok := c.shellProcessMap[notification.ProcessID] + c.shellProcessMapMux.Unlock() + + if ok { + session.dispatchShellExit(notification) + // Clean up the mapping after exit + c.shellProcessMapMux.Lock() + delete(c.shellProcessMap, notification.ProcessID) + c.shellProcessMapMux.Unlock() + session.untrackShellProcess(notification.ProcessID) + } +} + +func (c *Client) registerShellProcess(processID string, session *Session) { + c.shellProcessMapMux.Lock() + c.shellProcessMap[processID] = session + c.shellProcessMapMux.Unlock() +} + +func (c *Client) unregisterShellProcess(processID string) { + c.shellProcessMapMux.Lock() + delete(c.shellProcessMap, processID) + c.shellProcessMapMux.Unlock() +} + // handleUserInputRequest handles a user input request from the CLI server. func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { if req.SessionID == "" || req.Question == "" { diff --git a/go/session.go b/go/session.go index 2205ecb1..cefc0468 100644 --- a/go/session.go +++ b/go/session.go @@ -17,6 +17,16 @@ type sessionHandler struct { fn SessionEventHandler } +type shellOutputHandlerEntry struct { + id uint64 + fn ShellOutputHandler +} + +type shellExitHandlerEntry struct { + id uint64 + fn ShellExitHandler +} + // Session represents a single conversation session with the Copilot CLI. // // A session maintains conversation state, handles events, and manages tool execution. @@ -70,6 +80,15 @@ type Session struct { eventCh chan SessionEvent closeOnce sync.Once // guards eventCh close so Disconnect is safe to call more than once + shellOutputHandlers []shellOutputHandlerEntry + shellExitHandlers []shellExitHandlerEntry + shellHandlerMux sync.RWMutex + nextShellHandlerID uint64 + trackedProcessIDs map[string]struct{} + trackedProcessMux sync.Mutex + registerShellProc func(processID string, session *Session) + unregisterShellProc func(processID string) + // RPC provides typed session-scoped RPC methods. RPC *rpc.SessionRpc } @@ -84,13 +103,14 @@ func (s *Session) WorkspacePath() string { // newSession creates a new session wrapper with the given session ID and client. func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { s := &Session{ - SessionID: sessionID, - workspacePath: workspacePath, - client: client, - handlers: make([]sessionHandler, 0), - toolHandlers: make(map[string]ToolHandler), - eventCh: make(chan SessionEvent, 128), - RPC: rpc.NewSessionRpc(client, sessionID), + SessionID: sessionID, + workspacePath: workspacePath, + client: client, + handlers: make([]sessionHandler, 0), + toolHandlers: make(map[string]ToolHandler), + eventCh: make(chan SessionEvent, 128), + trackedProcessIDs: make(map[string]struct{}), + RPC: rpc.NewSessionRpc(client, sessionID), } go s.processEvents() return s @@ -264,6 +284,74 @@ func (s *Session) On(handler SessionEventHandler) func() { } } +// OnShellOutput subscribes to shell output notifications for this session. +// +// Shell output notifications are streamed in chunks when commands started +// via session.RPC.Shell.Exec() produce stdout or stderr output. +// +// The returned function can be called to unsubscribe the handler. +// +// Example: +// +// unsubscribe := session.OnShellOutput(func(n copilot.ShellOutputNotification) { +// fmt.Printf("[%s:%s] %s", n.ProcessID, n.Stream, n.Data) +// }) +// defer unsubscribe() +func (s *Session) OnShellOutput(handler ShellOutputHandler) func() { + s.shellHandlerMux.Lock() + defer s.shellHandlerMux.Unlock() + + id := s.nextShellHandlerID + s.nextShellHandlerID++ + s.shellOutputHandlers = append(s.shellOutputHandlers, shellOutputHandlerEntry{id: id, fn: handler}) + + return func() { + s.shellHandlerMux.Lock() + defer s.shellHandlerMux.Unlock() + + for i, h := range s.shellOutputHandlers { + if h.id == id { + s.shellOutputHandlers = append(s.shellOutputHandlers[:i], s.shellOutputHandlers[i+1:]...) + break + } + } + } +} + +// OnShellExit subscribes to shell exit notifications for this session. +// +// Shell exit notifications are sent when commands started via +// session.RPC.Shell.Exec() complete (after all output has been streamed). +// +// The returned function can be called to unsubscribe the handler. +// +// Example: +// +// unsubscribe := session.OnShellExit(func(n copilot.ShellExitNotification) { +// fmt.Printf("Process %s exited with code %d\n", n.ProcessID, n.ExitCode) +// }) +// defer unsubscribe() +func (s *Session) OnShellExit(handler ShellExitHandler) func() { + s.shellHandlerMux.Lock() + defer s.shellHandlerMux.Unlock() + + id := s.nextShellHandlerID + s.nextShellHandlerID++ + s.shellExitHandlers = append(s.shellExitHandlers, shellExitHandlerEntry{id: id, fn: handler}) + + return func() { + s.shellHandlerMux.Lock() + defer s.shellHandlerMux.Unlock() + + for i, h := range s.shellExitHandlers { + if h.id == id { + s.shellExitHandlers = append(s.shellExitHandlers[:i], s.shellExitHandlers[i+1:]...) + break + } + } + } +} + // registerTools registers tool handlers for this session. // // Tools allow the assistant to execute custom functions. When the assistant @@ -489,6 +577,67 @@ func (s *Session) processEvents() { } } +// dispatchShellOutput dispatches a shell output notification to all registered handlers. +func (s *Session) dispatchShellOutput(notification ShellOutputNotification) { + s.shellHandlerMux.RLock() + handlers := make([]ShellOutputHandler, 0, len(s.shellOutputHandlers)) + for _, h := range s.shellOutputHandlers { + handlers = append(handlers, h.fn) + } + s.shellHandlerMux.RUnlock() + + for _, handler := range handlers { + func() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("Error in shell output handler: %v\n", r) + } + }() + handler(notification) + }() + } +} + +// dispatchShellExit dispatches a shell exit notification to all registered handlers. +func (s *Session) dispatchShellExit(notification ShellExitNotification) { + s.shellHandlerMux.RLock() + handlers := make([]ShellExitHandler, 0, len(s.shellExitHandlers)) + for _, h := range s.shellExitHandlers { + handlers = append(handlers, h.fn) + } + s.shellHandlerMux.RUnlock() + + for _, handler := range handlers { + func() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("Error in shell exit handler: %v\n", r) + } + }() + handler(notification) + }() + } +} + +// untrackShellProcess stops tracking a shell process ID. +func (s *Session) untrackShellProcess(processID string) { + s.trackedProcessMux.Lock() + delete(s.trackedProcessIDs, processID) + s.trackedProcessMux.Unlock() + if s.unregisterShellProc != nil { + s.unregisterShellProc(processID) + } +} + +// setShellProcessCallbacks sets the registration callbacks for shell process tracking. +func (s *Session) setShellProcessCallbacks( + register func(processID string, session *Session), + unregister func(processID string), +) { + s.registerShellProc = register + s.unregisterShellProc = unregister +} + // handleBroadcastEvent handles broadcast request events by executing local handlers // and responding via RPC. This implements the protocol v3 broadcast model where tool // calls and permission requests are broadcast as session events to all clients. @@ -684,6 +833,20 @@ func (s *Session) Disconnect() error { s.permissionHandler = nil s.permissionMux.Unlock() + s.shellHandlerMux.Lock() + s.shellOutputHandlers = nil + s.shellExitHandlers = nil + s.shellHandlerMux.Unlock() + + s.trackedProcessMux.Lock() + for processID := range s.trackedProcessIDs { + if s.unregisterShellProc != nil { + s.unregisterShellProc(processID) + } + } + s.trackedProcessIDs = nil + s.trackedProcessMux.Unlock() + return nil } diff --git a/go/types.go b/go/types.go index 3ccbd0cc..94443065 100644 --- a/go/types.go +++ b/go/types.go @@ -658,6 +658,42 @@ type SessionLifecycleEventMetadata struct { // SessionLifecycleHandler is a callback for session lifecycle events type SessionLifecycleHandler func(event SessionLifecycleEvent) +// ShellOutputStream represents the output stream identifier for shell notifications. +type ShellOutputStream string + +const ( + // ShellStreamStdout represents standard output. + ShellStreamStdout ShellOutputStream = "stdout" + // ShellStreamStderr represents standard error. + ShellStreamStderr ShellOutputStream = "stderr" +) + +// ShellOutputNotification is sent when a shell command produces output. +// Streamed in chunks (up to 64KB per notification). +type ShellOutputNotification struct { + // ProcessID is the process identifier returned by shell.exec. + ProcessID string `json:"processId"` + // Stream indicates which output stream produced this chunk. + Stream ShellOutputStream `json:"stream"` + // Data is the output data (UTF-8 string). + Data string `json:"data"` +} + +// ShellExitNotification is sent when a shell command exits. +// Sent after all output has been streamed. +type ShellExitNotification struct { + // ProcessID is the process identifier returned by shell.exec. + ProcessID string `json:"processId"` + // ExitCode is the process exit code (0 = success). + ExitCode int `json:"exitCode"` +} + +// ShellOutputHandler is a callback for shell output notifications. +type ShellOutputHandler func(notification ShellOutputNotification) + +// ShellExitHandler is a callback for shell exit notifications. +type ShellExitHandler func(notification ShellExitNotification) + // createSessionRequest is the request for session.create type createSessionRequest struct { Model string `json:"model,omitempty"` diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0952122f..2e36437f 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.4-3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz", - "integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4-3.tgz", + "integrity": "sha512-tylmaiQh2OI+kpcY+1K757SKKDAG6yb9qDLw4GgRiF41O8T8h6bO5WJbsbB5DkbChUoerWC2dhlYvpu2LzDXyg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.4", - "@github/copilot-darwin-x64": "1.0.4", - "@github/copilot-linux-arm64": "1.0.4", - "@github/copilot-linux-x64": "1.0.4", - "@github/copilot-win32-arm64": "1.0.4", - "@github/copilot-win32-x64": "1.0.4" + "@github/copilot-darwin-arm64": "1.0.4-3", + "@github/copilot-darwin-x64": "1.0.4-3", + "@github/copilot-linux-arm64": "1.0.4-3", + "@github/copilot-linux-x64": "1.0.4-3", + "@github/copilot-win32-arm64": "1.0.4-3", + "@github/copilot-win32-x64": "1.0.4-3" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4-3.tgz", + "integrity": "sha512-sN7n9j4lE3iv4UqvrhxHmlWlG2h+uE6AFj6ETYM77JVdff3lcLhZf/ViMtqtkXpHHEJwOEkT0gc99sg+8gP7wg==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz", - "integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4-3.tgz", + "integrity": "sha512-ADoWHHHKeOIm9CvYjOeFhz7biIk2ldQzBkA/Ta2pYhZmOselpW+FQg8qO9ZGCOCdTEBOPVnFPZG/rRFH3pp8Sw==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz", - "integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4-3.tgz", + "integrity": "sha512-7Cj38BFSccib6EZjBxP3HC40MmNMzi32WMd3ujgA7VO6Es27h2LHeLvONTVMLXnYbmrAZvD+ZJrPmF43mDqUSg==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz", - "integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4-3.tgz", + "integrity": "sha512-HmWC2fp/h8Tc4xGCqmIXGkJu7ZkYcKLEGeWSYeYAyRrNf3rtBylFWUM2RYFc70TbwOKqGsc9k+vz7moGAqxXeA==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz", - "integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4-3.tgz", + "integrity": "sha512-S6Rgrp1xs4xHLL28YGf4jCNWwdIP7RFxvnJnjM3ounJCdVj1DDN9IKxlQqXKj+ELKUx7Q3j6Fe+inxI6WKSosA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz", - "integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4-3.tgz", + "integrity": "sha512-LtYUpdLrZlSz4xTNWSSjk5poYKDWPHbIQf02IctiUrY/E13Iht1lNerVn4Nrob+HQPDvDNQt9IJJXsq/V+E3aQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 214ef346..7085996a 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -44,7 +44,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.4-3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 783177c9..f130227f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -42,6 +42,8 @@ import type { SessionLifecycleHandler, SessionListFilter, SessionMetadata, + ShellExitNotification, + ShellOutputNotification, Tool, ToolCallRequestPayload, ToolCallResponsePayload, @@ -162,6 +164,7 @@ export class CopilotClient { Set<(event: SessionLifecycleEvent) => void> > = new Map(); private _rpc: ReturnType | null = null; + private shellProcessMap: Map = new Map(); private processExitPromise: Promise | null = null; // Rejects when CLI process exits private negotiatedProtocolVersion: number | null = null; @@ -557,6 +560,10 @@ export class CopilotClient { // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. const session = new CopilotSession(sessionId, this.connection!); + session._setShellProcessCallbacks( + (processId, session) => this.shellProcessMap.set(processId, session), + (processId) => this.shellProcessMap.delete(processId) + ); session.registerTools(config.tools); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -657,6 +664,10 @@ export class CopilotClient { // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. const session = new CopilotSession(sessionId, this.connection!); + session._setShellProcessCallbacks( + (processId, session) => this.shellProcessMap.set(processId, session), + (processId) => this.shellProcessMap.delete(processId) + ); session.registerTools(config.tools); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -1373,6 +1384,14 @@ export class CopilotClient { this.handleSessionLifecycleNotification(notification); }); + this.connection.onNotification("shell.output", (notification: unknown) => { + this.handleShellOutputNotification(notification); + }); + + this.connection.onNotification("shell.exit", (notification: unknown) => { + this.handleShellExitNotification(notification); + }); + // Protocol v3 servers send tool calls and permission requests as broadcast events // (external_tool.requested / permission.requested) handled in CopilotSession._dispatchEvent. // Protocol v2 servers use the older tool.call / permission.request RPC model instead. @@ -1474,6 +1493,43 @@ export class CopilotClient { } } + private handleShellOutputNotification(notification: unknown): void { + if ( + typeof notification !== "object" || + !notification || + !("processId" in notification) || + typeof (notification as { processId?: unknown }).processId !== "string" + ) { + return; + } + + const { processId } = notification as { processId: string }; + const session = this.shellProcessMap.get(processId); + if (session) { + session._dispatchShellOutput(notification as ShellOutputNotification); + } + } + + private handleShellExitNotification(notification: unknown): void { + if ( + typeof notification !== "object" || + !notification || + !("processId" in notification) || + typeof (notification as { processId?: unknown }).processId !== "string" + ) { + return; + } + + const { processId } = notification as { processId: string }; + const session = this.shellProcessMap.get(processId); + if (session) { + session._dispatchShellExit(notification as ShellExitNotification); + // Clean up the mapping after exit + this.shellProcessMap.delete(processId); + session._untrackShellProcess(processId); + } + } + private async handleUserInputRequest(params: { sessionId: string; question: string; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f2655f2f..0520c13a 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -42,6 +42,11 @@ export type { SessionContext, SessionListFilter, SessionMetadata, + ShellExitHandler, + ShellExitNotification, + ShellOutputHandler, + ShellOutputNotification, + ShellOutputStream, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageReplaceConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 4eb4b144..70fef5d0 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -20,6 +20,10 @@ import type { SessionEventPayload, SessionEventType, SessionHooks, + ShellExitHandler, + ShellExitNotification, + ShellOutputHandler, + ShellOutputNotification, Tool, ToolHandler, TypedSessionEventHandler, @@ -67,6 +71,11 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; + private shellOutputHandlers: Set = new Set(); + private shellExitHandlers: Set = new Set(); + private trackedProcessIds: Set = new Set(); + private _registerShellProcess?: (processId: string, session: CopilotSession) => void; + private _unregisterShellProcess?: (processId: string) => void; private _rpc: ReturnType | null = null; /** @@ -286,6 +295,52 @@ export class CopilotSession { }; } + /** + * Subscribe to shell output notifications for this session. + * + * Shell output notifications are streamed in chunks when commands started + * via `session.rpc.shell.exec()` produce stdout or stderr output. + * + * @param handler - Callback receiving shell output notifications + * @returns A function that, when called, unsubscribes the handler + * + * @example + * ```typescript + * const unsubscribe = session.onShellOutput((notification) => { + * console.log(`[${notification.processId}:${notification.stream}] ${notification.data}`); + * }); + * ``` + */ + onShellOutput(handler: ShellOutputHandler): () => void { + this.shellOutputHandlers.add(handler); + return () => { + this.shellOutputHandlers.delete(handler); + }; + } + + /** + * Subscribe to shell exit notifications for this session. + * + * Shell exit notifications are sent when commands started via + * `session.rpc.shell.exec()` complete (after all output has been streamed). + * + * @param handler - Callback receiving shell exit notifications + * @returns A function that, when called, unsubscribes the handler + * + * @example + * ```typescript + * const unsubscribe = session.onShellExit((notification) => { + * console.log(`Process ${notification.processId} exited with code ${notification.exitCode}`); + * }); + * ``` + */ + onShellExit(handler: ShellExitHandler): () => void { + this.shellExitHandlers.add(handler); + return () => { + this.shellExitHandlers.delete(handler); + }; + } + /** * Dispatches an event to all registered handlers. * Also handles broadcast request events internally (external tool calls, permissions). @@ -319,6 +374,59 @@ export class CopilotSession { } } + /** @internal */ + _dispatchShellOutput(notification: ShellOutputNotification): void { + for (const handler of this.shellOutputHandlers) { + try { + handler(notification); + } catch { + // Ignore handler errors + } + } + } + + /** @internal */ + _dispatchShellExit(notification: ShellExitNotification): void { + for (const handler of this.shellExitHandlers) { + try { + handler(notification); + } catch { + // Ignore handler errors + } + } + } + + /** + * Track a shell process ID so notifications are routed to this session. + * @internal + */ + _trackShellProcess(processId: string): void { + this.trackedProcessIds.add(processId); + this._registerShellProcess?.(processId, this); + } + + /** + * Stop tracking a shell process ID. + * @internal + */ + _untrackShellProcess(processId: string): void { + this.trackedProcessIds.delete(processId); + this._unregisterShellProcess?.(processId); + } + + /** + * Set the registration callbacks for shell process tracking. + * Called by the client when setting up the session. + * @internal + */ + _setShellProcessCallbacks( + register: (processId: string, session: CopilotSession) => void, + unregister: (processId: string) => void + ): void { + this._registerShellProcess = register; + this._unregisterShellProcess = unregister; + } + /** * Handles broadcast request events by executing local handlers and responding via RPC. * Handlers are dispatched as fire-and-forget — rejections propagate as unhandled promise @@ -645,6 +753,13 @@ export class CopilotSession { this.typedEventHandlers.clear(); this.toolHandlers.clear(); this.permissionHandler = undefined; + this.shellOutputHandlers.clear(); + this.shellExitHandlers.clear(); + // Unregister all tracked processes + for (const processId of this.trackedProcessIds) { + this._unregisterShellProcess?.(processId); + } + this.trackedProcessIds.clear(); } /** diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c7756a21..ca2574f5 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1086,6 +1086,49 @@ export type TypedSessionLifecycleHandler = event: SessionLifecycleEvent & { type: K } ) => void; +// ============================================================================ +// Shell Notification Types +// ============================================================================ + +/** + * Output stream identifier for shell notifications + */ +export type ShellOutputStream = "stdout" | "stderr"; + +/** + * Notification sent when a shell command produces output. + * Streamed in chunks (up to 64KB per notification). + */ +export interface ShellOutputNotification { + /** Process identifier returned by shell.exec */ + processId: string; + /** Which output stream produced this chunk */ + stream: ShellOutputStream; + /** The output data (UTF-8 string) */ + data: string; +} + +/** + * Notification sent when a shell command exits. + * Sent after all output has been streamed. + */ +export interface ShellExitNotification { + /** Process identifier returned by shell.exec */ + processId: string; + /** Process exit code (0 = success) */ + exitCode: number; +} + +/** + * Handler for shell output notifications + */ +export type ShellOutputHandler = (notification: ShellOutputNotification) => void; + +/** + * Handler for shell exit notifications + */ +export type ShellExitHandler = (notification: ShellExitNotification) => void; + /** * Information about the foreground session in TUI+server mode */ diff --git a/nodejs/test/session-shell.test.ts b/nodejs/test/session-shell.test.ts new file mode 100644 index 00000000..9d39bc93 --- /dev/null +++ b/nodejs/test/session-shell.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { CopilotSession } from "../src/session.js"; +import type { ShellOutputNotification, ShellExitNotification } from "../src/types.js"; + +// Create a minimal mock session for testing +function createMockSession(): CopilotSession { + const mockConnection = { + sendRequest: async () => ({}), + } as any; + return new CopilotSession("test-session", mockConnection); +} + +describe("CopilotSession shell notifications", () => { + describe("onShellOutput", () => { + it("should register and dispatch shell output notifications", () => { + const session = createMockSession(); + const received: ShellOutputNotification[] = []; + + session.onShellOutput((notification) => { + received.push(notification); + }); + + const notification: ShellOutputNotification = { + processId: "proc-1", + stream: "stdout", + data: "hello world\n", + }; + + session._dispatchShellOutput(notification); + + expect(received).toHaveLength(1); + expect(received[0]).toEqual(notification); + }); + + it("should support multiple handlers", () => { + const session = createMockSession(); + const received1: ShellOutputNotification[] = []; + const received2: ShellOutputNotification[] = []; + + session.onShellOutput((n) => received1.push(n)); + session.onShellOutput((n) => received2.push(n)); + + const notification: ShellOutputNotification = { + processId: "proc-1", + stream: "stderr", + data: "error output", + }; + + session._dispatchShellOutput(notification); + + expect(received1).toHaveLength(1); + expect(received2).toHaveLength(1); + }); + + it("should unsubscribe when the returned function is called", () => { + const session = createMockSession(); + const received: ShellOutputNotification[] = []; + + const unsubscribe = session.onShellOutput((n) => received.push(n)); + + session._dispatchShellOutput({ + processId: "proc-1", + stream: "stdout", + data: "first", + }); + + unsubscribe(); + + session._dispatchShellOutput({ + processId: "proc-1", + stream: "stdout", + data: "second", + }); + + expect(received).toHaveLength(1); + expect(received[0].data).toBe("first"); + }); + + it("should not crash when a handler throws", () => { + const session = createMockSession(); + const received: ShellOutputNotification[] = []; + + session.onShellOutput(() => { + throw new Error("handler error"); + }); + session.onShellOutput((n) => received.push(n)); + + session._dispatchShellOutput({ + processId: "proc-1", + stream: "stdout", + data: "test", + }); + + expect(received).toHaveLength(1); + }); + }); + + describe("onShellExit", () => { + it("should register and dispatch shell exit notifications", () => { + const session = createMockSession(); + const received: ShellExitNotification[] = []; + + session.onShellExit((notification) => { + received.push(notification); + }); + + const notification: ShellExitNotification = { + processId: "proc-1", + exitCode: 0, + }; + + session._dispatchShellExit(notification); + + expect(received).toHaveLength(1); + expect(received[0]).toEqual(notification); + }); + + it("should unsubscribe when the returned function is called", () => { + const session = createMockSession(); + const received: ShellExitNotification[] = []; + + const unsubscribe = session.onShellExit((n) => received.push(n)); + + session._dispatchShellExit({ processId: "proc-1", exitCode: 0 }); + unsubscribe(); + session._dispatchShellExit({ processId: "proc-2", exitCode: 1 }); + + expect(received).toHaveLength(1); + }); + }); + + describe("shell process tracking", () => { + it("should track and untrack process IDs via callbacks", () => { + const session = createMockSession(); + const registered = new Map(); + + session._setShellProcessCallbacks( + (processId, s) => registered.set(processId, s), + (processId) => registered.delete(processId) + ); + + session._trackShellProcess("proc-1"); + expect(registered.has("proc-1")).toBe(true); + + session._untrackShellProcess("proc-1"); + expect(registered.has("proc-1")).toBe(false); + }); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 99c14b33..814fcebb 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -33,6 +33,8 @@ SessionEvent, SessionListFilter, SessionMetadata, + ShellExitNotification, + ShellOutputNotification, StopError, SubprocessConfig, Tool, @@ -71,6 +73,8 @@ "SessionEvent", "SessionListFilter", "SessionMetadata", + "ShellExitNotification", + "ShellOutputNotification", "StopError", "SubprocessConfig", "Tool", diff --git a/python/copilot/client.py b/python/copilot/client.py index fd8b62bd..ffa842ff 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -46,6 +46,8 @@ SessionLifecycleHandler, SessionListFilter, SessionMetadata, + ShellExitNotification, + ShellOutputNotification, StopError, SubprocessConfig, ToolInvocation, @@ -182,6 +184,8 @@ def __init__( self._state: ConnectionState = "disconnected" self._sessions: dict[str, CopilotSession] = {} self._sessions_lock = threading.Lock() + self._shell_process_map: dict[str, CopilotSession] = {} + self._shell_process_map_lock = threading.Lock() self._models_cache: list[ModelInfo] | None = None self._models_cache_lock = asyncio.Lock() self._lifecycle_handlers: list[SessionLifecycleHandler] = [] @@ -601,6 +605,10 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: on_event = cfg.get("on_event") if on_event: session.on(on_event) + session._set_shell_process_callbacks( + register=self._register_shell_process, + unregister=self._unregister_shell_process, + ) with self._sessions_lock: self._sessions[session_id] = session @@ -802,6 +810,10 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> on_event = cfg.get("on_event") if on_event: session.on(on_event) + session._set_shell_process_callbacks( + register=self._register_shell_process, + unregister=self._unregister_shell_process, + ) with self._sessions_lock: self._sessions[session_id] = session @@ -1171,6 +1183,16 @@ def _dispatch_lifecycle_event(self, event: SessionLifecycleEvent) -> None: except Exception: pass # Ignore handler errors + def _register_shell_process(self, process_id: str, session: CopilotSession) -> None: + """Register a shell process ID mapping to a session.""" + with self._shell_process_map_lock: + self._shell_process_map[process_id] = session + + def _unregister_shell_process(self, process_id: str) -> None: + """Unregister a shell process ID mapping.""" + with self._shell_process_map_lock: + self._shell_process_map.pop(process_id, None) + async def _verify_protocol_version(self) -> None: """Verify that the server's protocol version is within the supported range and store the negotiated version.""" @@ -1411,6 +1433,26 @@ def handle_notification(method: str, params: dict): # Handle session lifecycle events lifecycle_event = SessionLifecycleEvent.from_dict(params) self._dispatch_lifecycle_event(lifecycle_event) + elif method == "shell.output": + process_id = params.get("processId") + if process_id: + with self._shell_process_map_lock: + session = self._shell_process_map.get(process_id) + if session: + notification = ShellOutputNotification.from_dict(params) + session._dispatch_shell_output(notification) + elif method == "shell.exit": + process_id = params.get("processId") + if process_id: + with self._shell_process_map_lock: + session = self._shell_process_map.get(process_id) + if session: + notification = ShellExitNotification.from_dict(params) + session._dispatch_shell_exit(notification) + # Clean up the mapping after exit + with self._shell_process_map_lock: + self._shell_process_map.pop(process_id, None) + session._untrack_shell_process(process_id) self._client.set_notification_handler(handle_notification) # Protocol v3 servers send tool calls / permission requests as broadcast events. @@ -1497,6 +1539,26 @@ def handle_notification(method: str, params: dict): # Handle session lifecycle events lifecycle_event = SessionLifecycleEvent.from_dict(params) self._dispatch_lifecycle_event(lifecycle_event) + elif method == "shell.output": + process_id = params.get("processId") + if process_id: + with self._shell_process_map_lock: + session = self._shell_process_map.get(process_id) + if session: + notification = ShellOutputNotification.from_dict(params) + session._dispatch_shell_output(notification) + elif method == "shell.exit": + process_id = params.get("processId") + if process_id: + with self._shell_process_map_lock: + session = self._shell_process_map.get(process_id) + if session: + notification = ShellExitNotification.from_dict(params) + session._dispatch_shell_exit(notification) + # Clean up the mapping after exit + with self._shell_process_map_lock: + self._shell_process_map.pop(process_id, None) + session._untrack_shell_process(process_id) self._client.set_notification_handler(handle_notification) # Protocol v3 servers send tool calls / permission requests as broadcast events. diff --git a/python/copilot/session.py b/python/copilot/session.py index b4ae210d..0fbc43c1 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -5,6 +5,8 @@ conversation sessions with the Copilot CLI. """ +from __future__ import annotations + import asyncio import inspect import threading @@ -29,6 +31,10 @@ PermissionRequest, PermissionRequestResult, SessionHooks, + ShellExitHandler, + ShellExitNotification, + ShellOutputHandler, + ShellOutputNotification, Tool, ToolHandler, ToolInvocation, @@ -96,6 +102,14 @@ def __init__(self, session_id: str, client: Any, workspace_path: str | None = No self._user_input_handler_lock = threading.Lock() self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() + self._shell_output_handlers: set[ShellOutputHandler] = set() + self._shell_exit_handlers: set[ShellExitHandler] = set() + self._shell_output_handlers_lock = threading.Lock() + self._shell_exit_handlers_lock = threading.Lock() + self._tracked_process_ids: set[str] = set() + self._tracked_process_ids_lock = threading.Lock() + self._register_shell_process: Callable[[str, CopilotSession], None] | None = None + self._unregister_shell_process_fn: Callable[[str], None] | None = None self._rpc: SessionRpc | None = None @property @@ -246,6 +260,106 @@ def unsubscribe(): return unsubscribe + def on_shell_output(self, handler: ShellOutputHandler) -> Callable[[], None]: + """Subscribe to shell output notifications for this session. + + Shell output notifications are streamed in chunks when commands started + via ``session.rpc.shell.exec()`` produce stdout or stderr output. + + Args: + handler: A callback that receives shell output notifications. + + Returns: + A function that, when called, unsubscribes the handler. + + Example: + >>> def handle_output(notification): + ... print(f"[{notification.processId}:{notification.stream}] {notification.data}") + >>> unsubscribe = session.on_shell_output(handle_output) + """ + with self._shell_output_handlers_lock: + self._shell_output_handlers.add(handler) + + def unsubscribe(): + with self._shell_output_handlers_lock: + self._shell_output_handlers.discard(handler) + + return unsubscribe + + def on_shell_exit(self, handler: ShellExitHandler) -> Callable[[], None]: + """Subscribe to shell exit notifications for this session. + + Shell exit notifications are sent when commands started via + ``session.rpc.shell.exec()`` complete (after all output has been streamed). + + Args: + handler: A callback that receives shell exit notifications. + + Returns: + A function that, when called, unsubscribes the handler. + + Example: + >>> def handle_exit(notification): + ... print(f"Process {notification.processId} exited: {notification.exitCode}") + >>> unsubscribe = session.on_shell_exit(handle_exit) + """ + with self._shell_exit_handlers_lock: + self._shell_exit_handlers.add(handler) + + def unsubscribe(): + with self._shell_exit_handlers_lock: + self._shell_exit_handlers.discard(handler) + + return unsubscribe + + def _dispatch_shell_output(self, notification: ShellOutputNotification) -> None: + """Dispatch a shell output notification to all registered handlers.""" + with self._shell_output_handlers_lock: + handlers = list(self._shell_output_handlers) + + for handler in handlers: + try: + handler(notification) + except Exception: + pass # Ignore handler errors + + def _dispatch_shell_exit(self, notification: ShellExitNotification) -> None: + """Dispatch a shell exit notification to all registered handlers.""" + with self._shell_exit_handlers_lock: + handlers = list(self._shell_exit_handlers) + + for handler in handlers: + try: + handler(notification) + except Exception: + pass # Ignore handler errors + + def _track_shell_process(self, process_id: str) -> None: + """Track a shell process ID so notifications are routed to this session.""" + with self._tracked_process_ids_lock: + self._tracked_process_ids.add(process_id) + if self._register_shell_process is not None: + self._register_shell_process(process_id, self) + + def _untrack_shell_process(self, process_id: str) -> None: + """Stop tracking a shell process ID.""" + with self._tracked_process_ids_lock: + self._tracked_process_ids.discard(process_id) + if self._unregister_shell_process_fn is not None: + self._unregister_shell_process_fn(process_id) + + def _set_shell_process_callbacks( + self, + register: Callable[[str, CopilotSession], None], + unregister: Callable[[str], None], + ) -> None: + """Set the registration callbacks for shell process tracking. + + Called by the client when setting up the session. + """ + self._register_shell_process = register + self._unregister_shell_process_fn = unregister + def _dispatch_event(self, event: SessionEvent) -> None: """ Dispatch an event to all registered handlers. @@ -668,6 +782,15 @@ async def disconnect(self) -> None: self._tool_handlers.clear() with self._permission_handler_lock: self._permission_handler = None + with self._shell_output_handlers_lock: + self._shell_output_handlers.clear() + with self._shell_exit_handlers_lock: + self._shell_exit_handlers.clear() + with self._tracked_process_ids_lock: + for process_id in list(self._tracked_process_ids): + if self._unregister_shell_process_fn is not None: + self._unregister_shell_process_fn(process_id) + self._tracked_process_ids.clear() async def destroy(self) -> None: """ @@ -689,7 +812,7 @@ async def destroy(self) -> None: ) await self.disconnect() - async def __aenter__(self) -> "CopilotSession": + async def __aenter__(self) -> CopilotSession: """Enable use as an async context manager.""" return self diff --git a/python/copilot/types.py b/python/copilot/types.py index 41989189..07c0e9aa 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -1145,3 +1145,61 @@ def from_dict(data: dict) -> SessionLifecycleEvent: # Handler types for session lifecycle events SessionLifecycleHandler = Callable[[SessionLifecycleEvent], None] + + +# ============================================================================ +# Shell Notification Types +# ============================================================================ + +ShellOutputStream = Literal["stdout", "stderr"] +"""Output stream identifier for shell notifications.""" + + +@dataclass +class ShellOutputNotification: + """Notification sent when a shell command produces output. + + Streamed in chunks (up to 64KB per notification). + """ + + processId: str + """Process identifier returned by shell.exec.""" + stream: ShellOutputStream + """Which output stream produced this chunk.""" + data: str + """The output data (UTF-8 string).""" + + @staticmethod + def from_dict(data: dict) -> ShellOutputNotification: + return ShellOutputNotification( + processId=data.get("processId", ""), + stream=data.get("stream", "stdout"), + data=data.get("data", ""), + ) + + +@dataclass +class ShellExitNotification: + """Notification sent when a shell command exits. + + Sent after all output has been streamed. + """ + + processId: str + """Process identifier returned by shell.exec.""" + exitCode: int + """Process exit code (0 = success).""" + + @staticmethod + def from_dict(data: dict) -> ShellExitNotification: + return ShellExitNotification( + processId=data.get("processId", ""), + exitCode=data.get("exitCode", 1), + ) + + +ShellOutputHandler = Callable[[ShellOutputNotification], None] +"""Handler for shell output notifications.""" + +ShellExitHandler = Callable[[ShellExitNotification], None] +"""Handler for shell exit notifications.""" diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index bf9564d9..dbc6f15f 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.4-3", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0", @@ -462,27 +462,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz", - "integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4-3.tgz", + "integrity": "sha512-tylmaiQh2OI+kpcY+1K757SKKDAG6yb9qDLw4GgRiF41O8T8h6bO5WJbsbB5DkbChUoerWC2dhlYvpu2LzDXyg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.4", - "@github/copilot-darwin-x64": "1.0.4", - "@github/copilot-linux-arm64": "1.0.4", - "@github/copilot-linux-x64": "1.0.4", - "@github/copilot-win32-arm64": "1.0.4", - "@github/copilot-win32-x64": "1.0.4" + "@github/copilot-darwin-arm64": "1.0.4-3", + "@github/copilot-darwin-x64": "1.0.4-3", + "@github/copilot-linux-arm64": "1.0.4-3", + "@github/copilot-linux-x64": "1.0.4-3", + "@github/copilot-win32-arm64": "1.0.4-3", + "@github/copilot-win32-x64": "1.0.4-3" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4-3.tgz", + "integrity": "sha512-sN7n9j4lE3iv4UqvrhxHmlWlG2h+uE6AFj6ETYM77JVdff3lcLhZf/ViMtqtkXpHHEJwOEkT0gc99sg+8gP7wg==", "cpu": [ "arm64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz", - "integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4-3.tgz", + "integrity": "sha512-ADoWHHHKeOIm9CvYjOeFhz7biIk2ldQzBkA/Ta2pYhZmOselpW+FQg8qO9ZGCOCdTEBOPVnFPZG/rRFH3pp8Sw==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz", - "integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4-3.tgz", + "integrity": "sha512-7Cj38BFSccib6EZjBxP3HC40MmNMzi32WMd3ujgA7VO6Es27h2LHeLvONTVMLXnYbmrAZvD+ZJrPmF43mDqUSg==", "cpu": [ "arm64" ], @@ -531,9 +531,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz", - "integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4-3.tgz", + "integrity": "sha512-HmWC2fp/h8Tc4xGCqmIXGkJu7ZkYcKLEGeWSYeYAyRrNf3rtBylFWUM2RYFc70TbwOKqGsc9k+vz7moGAqxXeA==", "cpu": [ "x64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz", - "integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4-3.tgz", + "integrity": "sha512-S6Rgrp1xs4xHLL28YGf4jCNWwdIP7RFxvnJnjM3ounJCdVj1DDN9IKxlQqXKj+ELKUx7Q3j6Fe+inxI6WKSosA==", "cpu": [ "arm64" ], @@ -565,9 +565,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz", - "integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==", + "version": "1.0.4-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4-3.tgz", + "integrity": "sha512-LtYUpdLrZlSz4xTNWSSjk5poYKDWPHbIQf02IctiUrY/E13Iht1lNerVn4Nrob+HQPDvDNQt9IJJXsq/V+E3aQ==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 9f336dfd..e3bf6ee7 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.4-3", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0",