diff --git a/docs/features/index.md b/docs/features/index.md index bbd005cb0..65a1f7535 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -17,6 +17,7 @@ These guides cover the capabilities you can add to your Copilot SDK application. | [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) | | [Steering & Queueing](./steering-and-queueing.md) | Control message delivery — immediate steering vs. sequential queueing | | [Session Persistence](./session-persistence.md) | Resume sessions across restarts, manage session storage | +| [Remote Sessions](./remote-sessions.md) | Share sessions to GitHub web and mobile via Mission Control | ## Related diff --git a/docs/features/remote-sessions.md b/docs/features/remote-sessions.md new file mode 100644 index 000000000..b935287ce --- /dev/null +++ b/docs/features/remote-sessions.md @@ -0,0 +1,163 @@ +# Remote Sessions + +Remote sessions let users access their Copilot session from GitHub web and mobile via [Mission Control](https://github.com). When enabled, the SDK connects each session to Mission Control, producing a URL that can be shared as a link or QR code. + +## Prerequisites + +- The user must be authenticated (GitHub token or logged-in user) +- The session's working directory must be a GitHub repository + +## Enabling Remote Sessions + +### Always-on (client-level) + +Set `remote: true` when creating the client. Every session in a GitHub repo automatically gets a remote URL. + + + +#### **TypeScript** + + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ remote: true }); +const session = await client.createSession({ + workingDirectory: "/path/to/github-repo", + onPermissionRequest: async () => ({ allowed: true }), +}); + +session.on("session.info", (event) => { + if (event.data.infoType === "remote") { + console.log("Remote URL:", event.data.url); + } +}); +``` + +#### **Python** + + +```python +from copilot import CopilotClient, SubprocessConfig + +client = CopilotClient(SubprocessConfig(remote=True)) +session = await client.create_session( + working_directory="/path/to/github-repo", + on_permission_request=lambda req: {"allowed": True}, +) + +def on_event(event): + if event.type == "session.info" and event.data.info_type == "remote": + print(f"Remote URL: {event.data.url}") + +session.on(on_event) +``` + +#### **Go** + + +```go +client, _ := copilot.NewClient(&copilot.ClientOptions{Remote: true}) +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + WorkingDirectory: "/path/to/github-repo", + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, +}) + +session.On(func(event copilot.SessionEvent) { + if event.Type == "session.info" { + // Check infoType and extract URL + } +}) +``` + +#### **C#** + + +```csharp +var client = new CopilotClient(new CopilotClientOptions { Remote = true }); +var session = await client.CreateSessionAsync(new SessionConfig +{ + WorkingDirectory = "/path/to/github-repo", + OnPermissionRequest = (req, inv) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), +}); + +session.On((SessionEvent e) => +{ + if (e is SessionInfoEvent info && info.Data.InfoType == "remote") + { + Console.WriteLine($"Remote URL: {info.Data.Url}"); + } +}); +``` + + + +### On-demand (per-session toggle) + +Use `session.rpc.remote.enable()` to start remote access mid-session, and `session.rpc.remote.disable()` to stop it. This is equivalent to the CLI's `/remote on` and `/remote off` commands. + + + +#### **TypeScript** + + +```typescript +const result = await session.rpc.remote.enable(); +console.log("Remote URL:", result.url); + +// Later: stop sharing +await session.rpc.remote.disable(); +``` + +#### **Python** + + +```python +result = await session.rpc.remote.enable() +print(f"Remote URL: {result.url}") + +# Later: stop sharing +await session.rpc.remote.disable() +``` + +#### **Go** + + +```go +result, err := session.RPC.Remote.Enable(ctx) +fmt.Println("Remote URL:", *result.URL) + +// Later: stop sharing +err = session.RPC.Remote.Disable(ctx) +``` + +#### **C#** + + +```csharp +var result = await session.Rpc.Remote.EnableAsync(); +Console.WriteLine($"Remote URL: {result.Url}"); + +// Later: stop sharing +await session.Rpc.Remote.DisableAsync(); +``` + + + +## QR Code Generation + +The remote URL can be rendered as a QR code for easy mobile access. The SDK provides the URL — use your preferred QR code library: + +- **TypeScript**: [qrcode](https://www.npmjs.com/package/qrcode) +- **Python**: [qrcode](https://pypi.org/project/qrcode/) +- **Go**: [go-qrcode](https://github.com/skip2/go-qrcode) +- **C#**: [QRCoder](https://www.nuget.org/packages/QRCoder) + +## Notes + +- The `remote` client option only applies when the SDK spawns the CLI process. It is ignored when connecting to an external server via `cliUrl`. +- If the working directory is not a GitHub repository, remote setup is silently skipped (always-on mode) or returns an error (on-demand mode). +- Remote sessions require authentication. Ensure `gitHubToken` or `useLoggedInUser` is configured. diff --git a/docs/index.md b/docs/index.md index 1b89439ae..89936df73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ Guides for building with the SDK's capabilities. - [Streaming Events](./features/streaming-events.md) — real-time event reference - [Steering & Queueing](./features/steering-and-queueing.md) — message delivery modes - [Session Persistence](./features/session-persistence.md) — resume sessions across restarts +- [Remote Sessions](./features/remote-sessions.md) — share sessions to GitHub web and mobile ### [Hooks Reference](./hooks/index.md) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index cfe37cf77..7b2fb4ad1 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1225,6 +1225,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio args.AddRange(["--session-idle-timeout", options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]); } + if (options.Remote) + { + args.Add("--remote"); + } + var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var startInfo = new ProcessStartInfo @@ -1261,6 +1266,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio startInfo.Environment["COPILOT_CONNECTION_TOKEN"] = connectionToken; } + // Set COPILOT_HOME if configured if (!string.IsNullOrEmpty(options.CopilotHome)) { startInfo.Environment["COPILOT_HOME"] = options.CopilotHome; diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 295c146b9..40b902044 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -2476,6 +2476,34 @@ internal sealed class SessionUsageGetMetricsRequest public string SessionId { get; set; } = string.Empty; } +/// RPC data type for RemoteEnable operations. +public sealed class RemoteEnableResult +{ + /// Whether remote steering is enabled. + [JsonPropertyName("remoteSteerable")] + public bool RemoteSteerable { get; set; } + + /// Mission Control frontend URL for this session. + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +/// RPC data type for SessionRemoteEnable operations. +internal sealed class SessionRemoteEnableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for SessionRemoteDisable operations. +internal sealed class SessionRemoteDisableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Describes a filesystem error. public sealed class SessionFsError { @@ -3419,6 +3447,7 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Shell = new ShellApi(rpc, sessionId); History = new HistoryApi(rpc, sessionId); Usage = new UsageApi(rpc, sessionId); + Remote = new RemoteApi(rpc, sessionId); } /// Auth APIs. @@ -3484,6 +3513,9 @@ internal SessionRpc(JsonRpc rpc, string sessionId) /// Usage APIs. public UsageApi Usage { get; } + /// Remote APIs. + public RemoteApi Remote { get; } + /// Calls "session.suspend". public async Task SuspendAsync(CancellationToken cancellationToken = default) { @@ -4163,6 +4195,33 @@ public async Task GetMetricsAsync(CancellationToken cance } } +/// Provides session-scoped Remote APIs. +public sealed class RemoteApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal RemoteApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.remote.enable". + public async Task EnableAsync(CancellationToken cancellationToken = default) + { + var request = new SessionRemoteEnableRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.remote.enable", [request], cancellationToken); + } + + /// Calls "session.remote.disable". + public async Task DisableAsync(CancellationToken cancellationToken = default) + { + var request = new SessionRemoteDisableRequest { SessionId = _sessionId }; + await CopilotClient.InvokeRpcAsync(_rpc, "session.remote.disable", [request], cancellationToken); + } +} + /// Handles `sessionFs` client session API methods. public interface ISessionFsHandler { @@ -4355,6 +4414,7 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, Func @@ -195,6 +196,15 @@ public string? GithubToken /// public string? TcpConnectionToken { get; set; } + /// + /// Enable remote session support (Mission Control integration). + /// When true, sessions in a GitHub repository working directory are + /// accessible from GitHub web and mobile. + /// This option is only used when the SDK spawns the CLI process; it is ignored + /// when connecting to an external server via . + /// + public bool Remote { get; set; } + /// /// Creates a shallow clone of this instance. /// diff --git a/go/client.go b/go/client.go index b61960a28..73ba8b2fe 100644 --- a/go/client.go +++ b/go/client.go @@ -235,6 +235,7 @@ func NewClient(options *ClientOptions) *Client { opts.CopilotHome = options.CopilotHome } opts.SessionIdleTimeoutSeconds = options.SessionIdleTimeoutSeconds + opts.Remote = options.Remote } // Default Env to current environment if not set @@ -1460,6 +1461,10 @@ func (c *Client) startCLIServer(ctx context.Context) error { args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds)) } + if c.options.Remote { + args = append(args, "--remote") + } + // If CLIPath is a .js file, run it with node // Note we can't rely on the shebang as Windows doesn't support it command := cliPath @@ -1491,6 +1496,11 @@ func (c *Client) startCLIServer(ctx context.Context) error { c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.CopilotHome) } + // Set COPILOT_HOME if configured + if c.options.CopilotHome != "" { + c.process.Env = append(c.process.Env, "COPILOT_HOME="+c.options.CopilotHome) + } + if c.options.Telemetry != nil { t := c.options.Telemetry c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_ENABLED", "true") diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index dd5ff61b8..86a1e63c3 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -246,6 +246,7 @@ type RPCTypes struct { UIElicitationStringOneOfField UIElicitationStringOneOfField `json:"UIElicitationStringOneOfField"` UIElicitationStringOneOfFieldOneOf UIElicitationStringOneOfFieldOneOf `json:"UIElicitationStringOneOfFieldOneOf"` UIHandlePendingElicitationRequest UIHandlePendingElicitationRequest `json:"UIHandlePendingElicitationRequest"` + RemoteEnableResult RemoteEnableResult `json:"RemoteEnableResult"` UsageGetMetricsResult UsageGetMetricsResult `json:"UsageGetMetricsResult"` UsageMetricsCodeChanges UsageMetricsCodeChanges `json:"UsageMetricsCodeChanges"` UsageMetricsModelMetric UsageMetricsModelMetric `json:"UsageMetricsModelMetric"` @@ -1947,6 +1948,13 @@ type UsageGetMetricsResult struct { TotalUserRequests int64 `json:"totalUserRequests"` } +type RemoteEnableResult struct { + // Mission Control frontend URL for this session + URL *string `json:"url,omitempty"` + // Whether remote steering is enabled + RemoteSteerable bool `json:"remoteSteerable"` +} + // Aggregated code change metrics type UsageMetricsCodeChanges struct { // Number of distinct files modified @@ -3663,6 +3671,27 @@ func (a *UsageApi) GetMetrics(ctx context.Context) (*UsageGetMetricsResult, erro return &result, nil } +type RemoteApi sessionApi + +func (a *RemoteApi) Enable(ctx context.Context) (*RemoteEnableResult, error) { + req := map[string]any{"sessionId": a.sessionID} + raw, err := a.client.Request("session.remote.enable", req) + if err != nil { + return nil, err + } + var result RemoteEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *RemoteApi) Disable(ctx context.Context) error { + req := map[string]any{"sessionId": a.sessionID} + _, err := a.client.Request("session.remote.disable", req) + return err +} + // SessionRpc provides typed session-scoped RPC methods. type SessionRpc struct { common sessionApi // Reuse a single struct instead of allocating one for each service on the heap. @@ -3688,6 +3717,7 @@ type SessionRpc struct { Shell *ShellApi History *HistoryApi Usage *UsageApi + Remote *RemoteApi } func (a *SessionRpc) Suspend(ctx context.Context) (*SuspendResult, error) { @@ -3752,6 +3782,7 @@ func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { r.Shell = (*ShellApi)(&r.common) r.History = (*HistoryApi)(&r.common) r.Usage = (*UsageApi)(&r.common) + r.Remote = (*RemoteApi)(&r.common) return r } diff --git a/go/types.go b/go/types.go index 2c1f6b67e..039744af8 100644 --- a/go/types.go +++ b/go/types.go @@ -90,6 +90,12 @@ type ClientOptions struct { // This option is only used when the SDK spawns the CLI process; it is ignored // when connecting to an external server via CLIUrl. SessionIdleTimeoutSeconds int + // Remote enables remote session support (Mission Control integration). + // When true, sessions in a GitHub repository working directory are + // accessible from GitHub web and mobile. + // This option is only used when the SDK spawns the CLI process; it is ignored + // when connecting to an external server via CLIUrl. + Remote bool } // TelemetryConfig configures OpenTelemetry integration for the Copilot CLI process. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3f4f702d0..d85f9e8b4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -385,6 +385,7 @@ export class CopilotClient { telemetry: options.telemetry, copilotHome: options.copilotHome, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, + remote: options.remote ?? false, }; } @@ -1486,6 +1487,10 @@ export class CopilotClient { ); } + if (this.options.remote) { + args.push("--remote"); + } + // Suppress debug/trace output that might pollute stdout const envWithoutNodeDebug = { ...this.options.env }; delete envWithoutNodeDebug.NODE_DEBUG; @@ -1499,6 +1504,7 @@ export class CopilotClient { envWithoutNodeDebug.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; } + // Set COPILOT_HOME if configured if (this.options.copilotHome) { envWithoutNodeDebug.COPILOT_HOME = this.options.copilotHome; } diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 6836324ab..72f13ae46 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1477,6 +1477,17 @@ export interface PluginList { plugins: Plugin[]; } +export interface RemoteEnableResult { + /** + * Mission Control frontend URL for this session + */ + url?: string; + /** + * Whether remote steering is enabled + */ + remoteSteerable: boolean; +} + export interface ServerSkill { /** * Unique identifier for the skill @@ -2495,6 +2506,8 @@ export function createServerRpc(connection: MessageConnection) { return { ping: async (params: PingRequest): Promise => connection.sendRequest("ping", params), + connect: async (params: ConnectRequest): Promise => + connection.sendRequest("connect", params), models: { list: async (params?: ModelsListRequest): Promise => connection.sendRequest("models.list", params), @@ -2722,6 +2735,12 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin getMetrics: async (): Promise => connection.sendRequest("session.usage.getMetrics", { sessionId }), }, + remote: { + enable: async (): Promise => + connection.sendRequest("session.remote.enable", { sessionId }), + disable: async (): Promise => + connection.sendRequest("session.remote.disable", { sessionId }), + }, }; } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 960d398a9..05aab9cae 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -211,6 +211,16 @@ export interface CopilotClientOptions { * `useStdio: true` (stdio is pre-authenticated by transport). */ tcpConnectionToken?: string; + + /** + * Enable remote session support (Mission Control integration). + * When true, sessions in a GitHub repository working directory are + * accessible from GitHub web and mobile. + * This option is only used when the SDK spawns the CLI process; it is ignored + * when connecting to an external server via {@link cliUrl}. + * @default false + */ + remote?: boolean; } /** diff --git a/python/copilot/client.py b/python/copilot/client.py index 44f244e9a..9ccdfa128 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -176,6 +176,14 @@ class SubprocessConfig: This option is only used when the SDK spawns the CLI process. """ + remote: bool = False + """Enable remote session support (Mission Control integration). + + When ``True``, sessions in a GitHub repository working directory are + accessible from GitHub web and mobile. + This option is only used when the SDK spawns the CLI process. + """ + @dataclass class ExternalServerConfig: @@ -2367,6 +2375,9 @@ async def _start_cli_server(self) -> None: if cfg.session_idle_timeout_seconds is not None and cfg.session_idle_timeout_seconds > 0: args.extend(["--session-idle-timeout", str(cfg.session_idle_timeout_seconds)]) + if cfg.remote: + args.append("--remote") + # If cli_path is a .js file, run it with node # Note that we can't rely on the shebang as Windows doesn't support it if cli_path.endswith(".js"): @@ -2386,6 +2397,8 @@ async def _start_cli_server(self) -> None: if self._effective_connection_token: env["COPILOT_CONNECTION_TOKEN"] = self._effective_connection_token + + # Set COPILOT_HOME if configured if cfg.copilot_home: env["COPILOT_HOME"] = cfg.copilot_home diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index fc3eb7bdf..f12d97e21 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -5036,6 +5036,26 @@ def to_dict(self) -> dict: result["totalNanoAiu"] = from_union([from_int, from_none], self.total_nano_aiu) return result +@dataclass +class RemoteEnableResult: + """Result of enabling remote session.""" + remote_steerable: bool + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'RemoteEnableResult': + assert isinstance(obj, dict) + remote_steerable = from_bool(obj.get("remoteSteerable")) + url = from_union([from_str, from_none], obj.get("url")) + return RemoteEnableResult(remote_steerable, url) + + def to_dict(self) -> dict: + result: dict = {} + result["remoteSteerable"] = from_bool(self.remote_steerable) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + @dataclass class WorkspacesGetWorkspaceResult: workspace: Workspace | None = None @@ -6798,6 +6818,18 @@ async def get_metrics(self, *, timeout: float | None = None) -> UsageGetMetricsR return UsageGetMetricsResult.from_dict(await self._client.request("session.usage.getMetrics", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) +class RemoteApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def enable(self, *, timeout: float | None = None) -> RemoteEnableResult: + return RemoteEnableResult.from_dict(await self._client.request("session.remote.enable", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def disable(self, *, timeout: float | None = None) -> None: + await self._client.request("session.remote.disable", {"sessionId": self._session_id}, **_timeout_kwargs(timeout)) + + class SessionRpc: """Typed session-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient", session_id: str): @@ -6824,6 +6856,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.shell = ShellApi(client, session_id) self.history = HistoryApi(client, session_id) self.usage = UsageApi(client, session_id) + self.remote = RemoteApi(client, session_id) async def suspend(self, *, timeout: float | None = None) -> None: await self._client.request("session.suspend", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))