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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ That's it! When your application calls `copilot.NewClient` without a `CLIPath` n
- `SetForegroundSessionID(ctx context.Context, sessionID string) error` - Request TUI to display a specific session (TUI+server mode only)
- `On(handler SessionLifecycleHandler) func()` - Subscribe to all lifecycle events; returns unsubscribe function
- `OnEventType(eventType SessionLifecycleEventType, handler SessionLifecycleHandler) func()` - Subscribe to specific lifecycle event type
- `CreateCloudSession(options *CloudSessionOptions) (*CloudSession, error)` - Create a sandbox-backed cloud session through Mission Control
- `ConnectCloudSession(taskOrSessionID string, options *CloudConnectOptions) (*CloudSession, error)` - Attach to an existing Mission Control cloud task

**Session Lifecycle Events:**

Expand Down Expand Up @@ -183,6 +185,40 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
- `UI() *SessionUI` - Interactive UI API for elicitation dialogs
- `Capabilities() SessionCapabilities` - Host capabilities (e.g. elicitation support)

### CloudSession

A `CloudSession` is a remote-control handle to a cloud sandbox running through Mission Control. It does not spawn a local CLI process.

- `Connect() error` - Fetch initial events and start background polling (called internally by `CreateCloudSession`/`ConnectCloudSession`)
- `On(handler CloudSessionEventHandler) func()` - Subscribe to events (returns unsubscribe function)
- `Send(options MessageOptions) error` - Send a user message through the steer API
- `SendAndWait(options MessageOptions, timeout time.Duration) (*CloudSessionEvent, error)` - Send and wait for session.idle
- `Abort() error` - Abort the currently running agent work
- `SubmitRemoteCommand(cmdType MissionControlCommandType, content string) error` - Send a raw steering command
- `RespondToPermission(payload CloudPermissionResponsePayload) error` - Respond to a permission request
- `RespondToAskUser(payload CloudAskUserResponsePayload) error` - Respond to an ask-user request
- `RespondToElicitation(payload CloudElicitationResponsePayload) error` - Respond to an elicitation
- `RespondToPlanApproval(payload CloudPlanApprovalResponsePayload) error` - Respond to a plan approval
- `SwitchMode(payload CloudModeSwitchPayload) error` - Switch the session mode
- `GetMessages() []CloudSessionEvent` - Get all received events
- `Disconnect()` - Stop polling and release resources

**CloudSessionOptions:**

- `Owner` (string): Billing/authorization owner. Required when `Repository` is omitted.
- `Repository` (*CloudRepository): Repository context (`Owner`, `Name`, optional `Branch`).
- `MissionControlBaseURL` (string): Override Mission Control base URL (default: `$COPILOT_MC_BASE_URL` or `$COPILOT_API_BASE_URL/agents`).
- `FrontendBaseURL` (string): Override GitHub frontend URL (default: `$COPILOT_MC_FRONTEND_URL` or `https://github.com`).
- `AuthToken` (string): Override auth token (default: `$COPILOT_MC_ACCESS_TOKEN` or `GitHubToken`).
- `IntegrationID` (string): Override `Copilot-Integration-Id` header (default: `copilot-cli`).
- `PollIntervalMs` (int): Event poll interval in ms (default: 5000).
- `InitialEventTimeoutMs` (*int): How long to wait for the first event in ms (default: 10000). Use `Int(0)` to skip waiting.
- `OnProgress` (func(CloudProgressEvent)): Progress callback.
- `OnCloudTaskCreated` (func(MissionControlTask)): Called after task creation.
- `OnEventPollError` (func(error)): Called on poll failures.

**Steering command types:** `CommandUserMessage`, `CommandAskUserResponse`, `CommandPlanApproval`, `CommandPermissionResponse`, `CommandElicitation`, `CommandAbort`, `CommandModeSwitch`

### Helper Functions

- `Bool(v bool) *bool` - Helper to create bool pointers for `AutoStart` option
Expand Down Expand Up @@ -856,6 +892,78 @@ Communicates with CLI via TCP socket. Useful for distributed scenarios.
## Environment Variables

- `COPILOT_CLI_PATH` - Path to the Copilot CLI executable
- `COPILOT_MC_BASE_URL` - Mission Control API base URL (cloud sessions)
- `COPILOT_API_BASE_URL` - Copilot API base URL (cloud sessions fallback)
- `COPILOT_MC_FRONTEND_URL` - GitHub frontend base URL (cloud sessions)
- `COPILOT_MC_ACCESS_TOKEN` - Mission Control auth token (cloud sessions)

## Cloud Sessions

Cloud sessions run agents inside provisioned cloud sandboxes managed by
Mission Control. The SDK acts as a remote-control client — it creates or
attaches to a cloud task, polls for events, and steers the agent.

### Create a cloud session (with repository)

```go
client := copilot.NewClient(&copilot.ClientOptions{
AutoStart: copilot.Bool(false),
GitHubToken: "ghp_...",
})

session, err := client.CreateCloudSession(&copilot.CloudSessionOptions{
Repository: &copilot.CloudRepository{
Owner: "github",
Name: "copilot-sdk",
},
})
if err != nil {
log.Fatal(err)
}
defer session.Disconnect()

session.On(func(event copilot.CloudSessionEvent) {
fmt.Println(event.Type)
})

session.Send(copilot.MessageOptions{Prompt: "Hello cloud!"})
```

### Create a repo-less cloud session

```go
session, err := client.CreateCloudSession(&copilot.CloudSessionOptions{
Owner: "github",
})
```

### Attach to an existing cloud task

```go
session, err := client.ConnectCloudSession("task-id", nil)
```

### Steering commands

```go
// Send a message
session.Send(copilot.MessageOptions{Prompt: "Fix the bug"})

// Abort current work
session.Abort()

// Respond to permission / ask-user / elicitation / plan approval
session.RespondToPermission(copilot.CloudPermissionResponsePayload{
PromptID: "p1", Approved: true, Scope: "once",
})
session.RespondToAskUser(copilot.CloudAskUserResponsePayload{
PromptID: "p2", Answer: "yes", WasFreeform: false,
})
session.SwitchMode(copilot.CloudModeSwitchPayload{Mode: "autopilot"})

// Raw steering
session.SubmitRemoteCommand(copilot.CommandUserMessage, "raw content")
```

## License

Expand Down
270 changes: 270 additions & 0 deletions go/cloud_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
package copilot

import (
"errors"
"os"
"strings"
"time"
)

// CreateCloudSession creates a sandbox-backed cloud session through Mission
// Control and attaches to it as a remote-control client.
//
// This does not create a local runtime session. The agent runs inside the
// provisioned cloud sandbox; this SDK instance polls Mission Control for
// events and sends user actions through the task steer API.
//
// Either options.Repository or options.Owner must be provided; when Repository
// is omitted, Owner is required for billing and authorization.
//
// Example:
//
// session, err := client.CreateCloudSession(&copilot.CloudSessionOptions{
// Repository: &copilot.CloudRepository{Owner: "github", Name: "copilot-sdk"},
// })
// if err != nil {
// log.Fatal(err)
// }
// defer session.Disconnect()
//
// session.On(func(event copilot.CloudSessionEvent) {
// fmt.Println(event.Type)
// })
// session.Send(copilot.MessageOptions{Prompt: "Hello from the cloud!"})
func (c *Client) CreateCloudSession(options *CloudSessionOptions) (*CloudSession, error) {
if options == nil {
options = &CloudSessionOptions{}
}
startedAt := time.Now()

mcClient := c.buildMissionControlClient(
options.MissionControlBaseURL,
options.CopilotAPIBaseURL,
options.FrontendBaseURL,
options.AuthToken,
options.IntegrationID,
)

owner := strings.TrimSpace(options.Owner)
repo := options.Repository

if repo == nil && owner == "" {
return nil, errors.New("CloudSessionOptions.Owner is required when Repository is omitted")
}

if options.OnProgress != nil {
options.OnProgress(CloudProgressEvent{Phase: CloudProgressCreatingTask, ElapsedMs: 0})
options.OnProgress(CloudProgressEvent{
Phase: CloudProgressProvisioningSandbox,
ElapsedMs: time.Since(startedAt).Milliseconds(),
})
}

var taskRepo *CloudRepository
if repo != nil {
taskRepo = &CloudRepository{Owner: repo.Owner, Name: repo.Name}
}
task, err := mcClient.createCloudTask(owner, taskRepo)
if err != nil {
return nil, err
}
if options.OnCloudTaskCreated != nil {
options.OnCloudTaskCreated(*task)
}

if options.OnProgress != nil {
options.OnProgress(CloudProgressEvent{
Phase: CloudProgressWaitingForSession,
ElapsedMs: time.Since(startedAt).Milliseconds(),
TaskID: task.ID,
})
}

session := newCloudSession(cloudSessionConfig{
client: mcClient,
metadata: c.buildCloudSessionMetadata(task, mcClient, repo, owner),
pollIntervalMs: options.PollIntervalMs,
initialEventTimeoutMs: options.InitialEventTimeoutMs,
initialEventPollIntervalMs: options.InitialEventPollIntervalMs,
onEventPollError: options.OnEventPollError,
})

if err := session.Connect(); err != nil {
return nil, err
}

if options.OnProgress != nil {
options.OnProgress(CloudProgressEvent{
Phase: CloudProgressConnected,
ElapsedMs: time.Since(startedAt).Milliseconds(),
TaskID: task.ID,
})
}

return session, nil
}

// ConnectCloudSession attaches to an existing Mission Control cloud task as a
// remote-control client.
//
// The taskOrSessionID is treated as a Mission Control task ID. If Mission
// Control returns task metadata, it is used to populate the session metadata;
// otherwise the SDK still attaches by polling task events for the provided ID.
//
// Example:
//
// session, err := client.ConnectCloudSession("task-1", nil)
// if err != nil {
// log.Fatal(err)
// }
// defer session.Disconnect()
func (c *Client) ConnectCloudSession(taskOrSessionID string, options *CloudConnectOptions) (*CloudSession, error) {
if options == nil {
options = &CloudConnectOptions{}
}
startedAt := time.Now()

mcClient := c.buildMissionControlClient(
options.MissionControlBaseURL,
options.CopilotAPIBaseURL,
options.FrontendBaseURL,
options.AuthToken,
options.IntegrationID,
)

if options.OnProgress != nil {
options.OnProgress(CloudProgressEvent{
Phase: CloudProgressWaitingForSession,
ElapsedMs: 0,
TaskID: taskOrSessionID,
})
}

owner := strings.TrimSpace(options.Owner)
task, err := mcClient.getTask(taskOrSessionID)
if err != nil {
return nil, err
}

var metadata CloudSessionMetadata
if task != nil {
metadata = c.buildCloudSessionMetadata(task, mcClient, options.Repository, owner)
} else {
metadata = c.buildFallbackCloudSessionMetadata(taskOrSessionID, mcClient, options.Repository, owner)
}

session := newCloudSession(cloudSessionConfig{
client: mcClient,
metadata: metadata,
pollIntervalMs: options.PollIntervalMs,
initialEventTimeoutMs: options.InitialEventTimeoutMs,
initialEventPollIntervalMs: options.InitialEventPollIntervalMs,
onEventPollError: options.OnEventPollError,
})

if err := session.Connect(); err != nil {
return nil, err
}

if options.OnProgress != nil {
options.OnProgress(CloudProgressEvent{
Phase: CloudProgressConnected,
ElapsedMs: time.Since(startedAt).Milliseconds(),
TaskID: metadata.TaskID,
})
}

return session, nil
}

// ── helpers ─────────────────────────────────────────────────────────────

func (c *Client) buildMissionControlClient(
mcBaseURL, copilotAPIBaseURL, frontendBaseURL, authToken, integrationID string,
) *missionControlClient {
copilotAPI := firstNonEmpty(
copilotAPIBaseURL,
os.Getenv("COPILOT_API_BASE_URL"),
os.Getenv("COPILOT_API_URL"),
"https://api.githubcopilot.com",
)
copilotAPI = strings.TrimRight(copilotAPI, "/")

baseURL := firstNonEmpty(
mcBaseURL,
os.Getenv("COPILOT_MC_BASE_URL"),
copilotAPI+"/agents",
)

token := firstNonEmpty(
strings.TrimSpace(authToken),
strings.TrimSpace(os.Getenv("COPILOT_MC_ACCESS_TOKEN")),
c.options.GitHubToken,
)

frontend := firstNonEmpty(
frontendBaseURL,
os.Getenv("COPILOT_MC_FRONTEND_URL"),
"https://github.com",
)

return newMissionControlClient(missionControlClientConfig{
BaseURL: baseURL,
AuthToken: token,
IntegrationID: integrationID,
FrontendBaseURL: frontend,
})
}

func (c *Client) buildCloudSessionMetadata(
task *MissionControlTask,
mcClient *missionControlClient,
repository *CloudRepository,
owner string,
) CloudSessionMetadata {
var mcSessionID string
if len(task.Sessions) > 0 {
mcSessionID = task.Sessions[len(task.Sessions)-1].ID
}

createdAt, _ := time.Parse(time.RFC3339, task.CreatedAt)
updatedAt, _ := time.Parse(time.RFC3339, task.UpdatedAt)

Comment on lines +229 to +232
return CloudSessionMetadata{
TaskID: task.ID,
MissionControlSessionID: mcSessionID,
FrontendURL: mcClient.getFrontendURL(task.ID),
Owner: owner,
Repository: repository,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
State: task.State,
Status: task.Status,
}
}

func (c *Client) buildFallbackCloudSessionMetadata(
taskID string,
mcClient *missionControlClient,
repository *CloudRepository,
owner string,
) CloudSessionMetadata {
now := time.Now()
return CloudSessionMetadata{
TaskID: taskID,
FrontendURL: mcClient.getFrontendURL(taskID),
Owner: owner,
Repository: repository,
CreatedAt: now,
UpdatedAt: now,
}
}

func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
Loading
Loading