package copilot
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/github/copilot-sdk/go/internal/embeddedcli"
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
"github.com/github/copilot-sdk/go/internal/truncbuffer"
"github.com/github/copilot-sdk/go/rpc"
)
const defaultBearerTokenProviderName = "default"
type cloudSkillProviderError struct{}
func (cloudSkillProviderError) Error() string {
return "Skill providers are not supported for cloud sessions."
}
func collectBearerTokenProviders(provider *ProviderConfig, providers []NamedProviderConfig) map[string]BearerTokenProvider {
callbacks := make(map[string]BearerTokenProvider)
if provider != nil && provider.BearerTokenProvider != nil {
callbacks[defaultBearerTokenProviderName] = provider.BearerTokenProvider
}
for i := range providers {
if providers[i].BearerTokenProvider != nil {
callbacks[providers[i].Name] = providers[i].BearerTokenProvider
}
}
if len(callbacks) == 0 {
return nil
}
return callbacks
}
func validateSessionFSConfig(config *SessionFSConfig) error {
if config == nil {
return nil
}
if config.InitialWorkingDirectory == "" {
return errors.New("SessionFS.InitialWorkingDirectory is required")
}
if config.SessionStatePath == "" {
return errors.New("SessionFS.SessionStatePath is required")
}
if config.Conventions != rpc.SessionFSSetProviderConventionsPosix && config.Conventions != rpc.SessionFSSetProviderConventionsWindows {
return errors.New("SessionFS.Conventions must be either 'posix' or 'windows'")
}
return nil
}
func validateEnvironmentOptions(connection RuntimeConnection, opts *ClientOptions) {
if _, ok := connection.(InProcessConnection); ok {
if opts.Env != nil {
panic("Env is not supported with InProcessConnection: the in-process transport loads the native runtime into the shared host process, whose single environment block cannot carry per-client values. Set the variables on the host process environment instead.")
}
if opts.WorkingDirectory != "" {
panic("WorkingDirectory is not supported with InProcessConnection: the native runtime shares the host process working directory. Use a child-process transport, or set the process working directory before creating the client.")
}
if opts.Telemetry != nil {
panic("Telemetry is not supported with InProcessConnection: telemetry configuration is lowered to environment variables read by native runtime code running in the shared host process, so per-client telemetry cannot be honored in-process. Configure telemetry via the host process environment, or use a child-process transport.")
}
return
}
if cp, ok := connection.(childProcessConnection); ok {
if cp.connEnv() != nil && opts.Env != nil {
panic("Set environment variables via either the client-level Env option or the connection's Env, not both. Prefer the connection-level Env for child-process transports.")
}
}
}
type Client struct {
options ClientOptions
process *exec.Cmd
processStdin io.WriteCloser
client *jsonrpc2.Client
actualPort int
actualHost string
state connectionState
sessions map[string]*Session
sessionsMux sync.Mutex
ahp ahpHostState
gitHubTokenProviders map[string]GitHubTokenProvider
gitHubTokenProvidersMux sync.RWMutex
requestAdapter *copilotRequestAdapter
requestAdapterMux sync.Mutex
installationConfirmationAdapter *installationConfirmationAdapter
installationConfirmationAdapterMux sync.Mutex
sessionOperations map[string]*sessionOperation
sessionOperationsMux sync.Mutex
isExternalServer bool
conn net.Conn
useStdio bool
useInProcess bool
ffiHost inProcessHost
runOnUiThread func(action func() error) error
cliPath string
cliArgs []string
port int
tcpConnectionToken string
modelsCache []ModelInfo
modelsCacheMux sync.Mutex
lifecycleHandlers map[uint64]SessionLifecycleHandler
typedLifecycleHandlers map[SessionLifecycleEventType]map[uint64]SessionLifecycleHandler
nextLifecycleHandlerID uint64
lifecycleHandlersMux sync.Mutex
startStopMux sync.RWMutex
processDone chan struct{}
processErrorPtr *error
osProcess atomic.Pointer[os.Process]
negotiatedProtocolVersion int
effectiveConnectionToken string
onListModels func(ctx context.Context) ([]ModelInfo, error)
RPC *rpc.ServerRPC
internalRPC *rpc.InternalServerRPC
}
type sessionOperation struct {
mutex sync.Mutex
users int
}
func NewClient(options *ClientOptions) *Client {
opts := ClientOptions{}
client := &Client{
options: opts,
state: stateDisconnected,
sessions: make(map[string]*Session),
gitHubTokenProviders: make(map[string]GitHubTokenProvider),
actualHost: "localhost",
isExternalServer: false,
useStdio: true,
}
if options != nil {
opts = *options
}
for _, path := range opts.BuiltinPluginDirectories {
if !filepath.IsAbs(path) {
panic(fmt.Sprintf("BuiltinPluginDirectories must contain only absolute paths: %s", path))
}
}
opts.BuiltinPluginDirectories = append([]string(nil), opts.BuiltinPluginDirectories...)
connection := opts.Connection
if connection == nil {
env := opts.Env
if env == nil {
env = os.Environ()
}
connection = resolveDefaultConnection(env)
}
switch conn := connection.(type) {
case StdioConnection:
client.useStdio = true
client.cliPath = conn.Path
if len(conn.Args) > 0 {
client.cliArgs = append([]string{}, conn.Args...)
}
case TCPConnection:
client.useStdio = false
client.cliPath = conn.Path
if len(conn.Args) > 0 {
client.cliArgs = append([]string{}, conn.Args...)
}
client.port = conn.Port
client.tcpConnectionToken = conn.ConnectionToken
case URIConnection:
if conn.URL == "" {
panic("URIConnection requires a non-empty URL")
}
host, port := parseCLIURL(conn.URL)
client.actualHost = host
client.actualPort = port
client.isExternalServer = true
client.useStdio = false
client.tcpConnectionToken = conn.ConnectionToken
case InProcessConnection:
client.useStdio = false
client.useInProcess = true
client.runOnUiThread = conn.RunOnUiThread
default:
panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection))
}
validateEnvironmentOptions(connection, &opts)
if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) {
panic("GitHubToken and UseLoggedInUser cannot be used with URIConnection (external runtime manages its own auth)")
}
if cp, ok := connection.(childProcessConnection); ok {
if env := cp.connEnv(); env != nil {
opts.Env = env
}
}
if opts.Env == nil {
opts.Env = os.Environ()
}
if client.cliPath == "" && !client.useInProcess {
if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" {
client.cliPath = cliPath
}
}
if client.tcpConnectionToken != "" {
client.effectiveConnectionToken = client.tcpConnectionToken
} else if !client.useStdio && !client.isExternalServer && !client.useInProcess {
client.effectiveConnectionToken = uuid.NewString()
}
if opts.OnListModels != nil {
client.onListModels = opts.OnListModels
}
if opts.SessionFS != nil {
if err := validateSessionFSConfig(opts.SessionFS); err != nil {
panic(err.Error())
}
}
client.options = opts
validateNewClientForMode(&client.options)
return client
}
func resolveRuntimeExecutable(explicitPath, bundledRuntimePath string) (string, error) {
if explicitPath != "" {
return explicitPath, nil
}
if bundledRuntimePath == "" {
return "", errors.New(
"managed Copilot runtime unavailable: the embedded bundle does not contain copilot-runtime and adjacent runtime.node; regenerate the bundle, provide an explicit path, or set COPILOT_CLI_PATH",
)
}
return bundledRuntimePath, nil
}
const defaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION"
func resolveDefaultConnection(env []string) RuntimeConnection {
value := getEnvValue(env, defaultConnectionEnvVar)
switch {
case value == "", strings.EqualFold(value, "stdio"):
return StdioConnection{}
case strings.EqualFold(value, "inprocess"):
return InProcessConnection{}
default:
panic(fmt.Sprintf(
"invalid %s value %q: expected \"inprocess\", \"stdio\", or unset",
defaultConnectionEnvVar,
value,
))
}
}
func getEnvValue(env []string, key string) string {
prefix := key + "="
for i := len(env) - 1; i >= 0; i-- {
if strings.HasPrefix(env[i], prefix) {
return env[i][len(prefix):]
}
}
return ""
}
func setEnvValue(env []string, key string, value string) []string {
prefix := key + "="
filtered := make([]string, 0, len(env)+1)
for _, entry := range env {
if !strings.HasPrefix(entry, prefix) {
filtered = append(filtered, entry)
}
}
return append(filtered, key+"="+value)
}
func parseCLIURL(url string) (string, int) {
cleanURL, _ := strings.CutPrefix(url, "https://")
cleanURL, _ = strings.CutPrefix(cleanURL, "http://")
if strings.HasPrefix(cleanURL, "[") {
host, portStr, err := net.SplitHostPort(cleanURL)
if err != nil {
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
}
addr, err := netip.ParseAddr(host)
if err != nil || !addr.Is6() {
panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
}
return host, port
}
var host string
var portStr string
if before, after, found := strings.Cut(cleanURL, ":"); found {
host = before
portStr = after
} else {
portStr = cleanURL
}
if host == "" {
host = "localhost"
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
}
return host, port
}
func (c *Client) Start(ctx context.Context) error {
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
if c.state == stateConnected {
return nil
}
c.state = stateConnecting
if !c.isExternalServer {
if err := c.startCLIServer(ctx); err != nil {
c.process = nil
c.state = stateError
return err
}
}
if err := c.connectToServer(ctx); err != nil {
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
if err := c.verifyProtocolVersion(ctx); err != nil {
c.client.Stop()
c.client = nil
c.conn = nil
c.RPC = nil
c.internalRPC = nil
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
if c.options.ExtensionLaunchProvider != nil {
if _, err := c.RPC.RegisterExtensionLaunchProvider(ctx); err != nil {
c.client.Stop()
c.client = nil
c.conn = nil
c.RPC = nil
c.internalRPC = nil
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
}
if len(c.options.BuiltinPluginDirectories) > 0 {
if _, err := c.client.Request(ctx, "plugins.builtin.set", map[string]any{
"paths": c.options.BuiltinPluginDirectories,
}); err != nil {
c.client.Stop()
c.client = nil
c.conn = nil
c.RPC = nil
c.internalRPC = nil
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
}
if c.options.SessionFS != nil {
req := &rpc.SessionFSSetProviderRequest{
InitialCwd: c.options.SessionFS.InitialWorkingDirectory,
SessionStatePath: c.options.SessionFS.SessionStatePath,
Conventions: c.options.SessionFS.Conventions,
}
if c.options.SessionFS.Capabilities != nil {
sqlite := c.options.SessionFS.Capabilities.Sqlite
binary := c.options.SessionFS.Capabilities.Binary
req.Capabilities = &rpc.SessionFSSetProviderCapabilities{
Sqlite: &sqlite,
Binary: &binary,
}
}
_, err := c.RPC.SessionFS.SetProvider(ctx, req)
if err != nil {
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
}
if c.options.RequestHandler != nil {
if _, err := c.RPC.LlmInference.SetProvider(ctx); err != nil {
killErr := c.killProcess()
c.state = stateError
return errors.Join(err, killErr)
}
}
c.state = stateConnected
return nil
}
func (c *Client) Stop() error {
c.disconnectAhpHosts()
var errs []error
c.sessionsMux.Lock()
sessions := make([]*Session, 0, len(c.sessions))
for _, session := range c.sessions {
sessions = append(sessions, session)
}
c.sessionsMux.Unlock()
for _, session := range sessions {
if err := session.Disconnect(); err != nil {
errs = append(errs, fmt.Errorf("failed to disconnect session %s: %w", session.SessionID, err))
}
}
c.sessionsMux.Lock()
c.sessions = make(map[string]*Session)
c.sessionsMux.Unlock()
c.clearGitHubTokenProviders()
c.closeCopilotRequestAdapter()
c.closeInstallationConfirmationAdapter()
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
if (c.process != nil || c.ffiHost != nil) && !c.isExternalServer && c.RPC != nil {
rpcClient := c.RPC
runtimeShutdownStart := time.Now()
shutdownDone := make(chan error, 1)
go func() {
_, err := rpcClient.Runtime.Shutdown(context.Background())
shutdownDone <- err
}()
select {
case err := <-shutdownDone:
if err != nil {
c.logDebugTiming(runtimeShutdownStart, "CopilotClient.Stop runtime shutdown failed")
errs = append(errs, fmt.Errorf("failed to gracefully shut down runtime: %w", err))
} else {
c.logDebugTiming(runtimeShutdownStart, "CopilotClient.Stop runtime shutdown complete")
}
case <-time.After(runtimeShutdownTimeout):
c.logDebugTiming(runtimeShutdownStart, "CopilotClient.Stop runtime shutdown timed out")
errs = append(errs, fmt.Errorf("timed out gracefully shutting down runtime after %s", runtimeShutdownTimeout))
}
}
if c.process != nil && !c.isExternalServer && c.processStdin != nil {
processExitStart := time.Now()
if err := c.processStdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
errs = append(errs, fmt.Errorf("failed to close CLI stdin: %w", err))
}
c.processStdin = nil
select {
case <-c.processDone:
c.logDebugTiming(processExitStart, "CopilotClient.Stop CLI process exited gracefully")
c.osProcess.Store(nil)
c.process = nil
case <-time.After(processExitTimeout):
c.logDebugTiming(processExitStart, "CopilotClient.Stop CLI process exit timed out; killing process")
}
}
if c.process != nil && !c.isExternalServer {
if err := c.killProcessAndWait(); err != nil {
errs = append(errs, err)
}
}
c.process = nil
if c.ffiHost != nil {
c.ffiHost.Dispose()
c.ffiHost = nil
}
if c.isExternalServer && c.conn != nil {
if err := c.conn.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close socket: %w", err))
}
c.conn = nil
}
if c.client != nil {
c.client.Stop()
c.client = nil
}
c.modelsCacheMux.Lock()
c.modelsCache = nil
c.modelsCacheMux.Unlock()
c.state = stateDisconnected
if !c.isExternalServer {
c.actualPort = 0
}
c.RPC = nil
c.internalRPC = nil
return errors.Join(errs...)
}
func (c *Client) logDebugTiming(start time.Time, message string) {
switch strings.ToLower(c.options.LogLevel) {
case "debug", "all":
log.Printf("%s elapsed=%s", message, time.Since(start))
}
}
func (c *Client) ForceStop() {
c.disconnectAhpHosts()
if p := c.osProcess.Swap(nil); p != nil {
p.Kill()
}
c.sessionsMux.Lock()
sessions := make([]*Session, 0, len(c.sessions))
for _, session := range c.sessions {
sessions = append(sessions, session)
}
c.sessions = make(map[string]*Session)
c.sessionsMux.Unlock()
for _, session := range sessions {
session.clearSkillProvider()
session.cancelPendingExternalTools()
session.stopEventProcessing()
}
c.clearGitHubTokenProviders()
c.closeCopilotRequestAdapter()
c.closeInstallationConfirmationAdapter()
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
if c.process != nil && !c.isExternalServer {
_ = c.killProcess()
}
c.process = nil
if c.ffiHost != nil {
c.ffiHost.Dispose()
c.ffiHost = nil
}
if c.isExternalServer && c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
if c.client != nil {
c.client.Stop()
c.client = nil
}
c.modelsCacheMux.Lock()
c.modelsCache = nil
c.modelsCacheMux.Unlock()
c.state = stateDisconnected
if !c.isExternalServer {
c.actualPort = 0
}
c.RPC = nil
c.internalRPC = nil
}
func (c *Client) ensureConnected(ctx context.Context) error {
if c.client != nil {
return nil
}
return c.Start(ctx)
}
func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfig, map[string]SectionTransformFn) {
if config == nil || config.Mode != "customize" || len(config.Sections) == 0 {
return config, nil
}
callbacks := make(map[string]SectionTransformFn)
wireSections := make(map[string]SectionOverride)
for id, override := range config.Sections {
if override.Transform != nil {
callbacks[id] = override.Transform
wireSections[id] = SectionOverride{Action: "transform"}
} else {
wireSections[id] = override
}
}
if len(callbacks) == 0 {
return config, nil
}
wireConfig := &SystemMessageConfig{
Mode: config.Mode,
Content: config.Content,
Sections: wireSections,
}
return wireConfig, callbacks
}
func hasManagedSettings(enableManagedSettings *bool, managedSettings *ManagedSettings) bool {
return (enableManagedSettings != nil && *enableManagedSettings) || managedSettings != nil
}
func validateAskUserVariant(variant AskUserVariant) error {
if variant != "" && variant != AskUserVariantLegacy && variant != AskUserVariantElicitation {
return fmt.Errorf("invalid AskUserVariant %q: expected %q, %q, or unset", variant, AskUserVariantLegacy, AskUserVariantElicitation)
}
return nil
}
func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) {
return c.createSession(ctx, config, nil)
}
func (c *Client) CreateSessionWithMCPAuthRuntimeRefresh(ctx context.Context, config *SessionConfig, handler MCPAuthRuntimeRefreshHandler) (*Session, error) {
return c.createSession(ctx, config, handler)
}
func (c *Client) createSession(ctx context.Context, config *SessionConfig, runtimeRefreshHandler MCPAuthRuntimeRefreshHandler) (*Session, error) {
if config == nil {
config = &SessionConfig{}
}
if config.Cloud != nil && config.SkillProvider != nil {
return nil, cloudSkillProviderError{}
}
if config.GitHubToken != "" && config.GitHubTokenProvider != nil {
return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together")
}
if err := validateAskUserVariant(config.AskUserVariant); err != nil {
return nil, err
}
if trust := config.TrustWorkingDirectory; trust != "" && trust != WorkingDirectoryTrustSession && trust != WorkingDirectoryTrustUser {
return nil, fmt.Errorf("invalid TrustWorkingDirectory %q: expected %q, %q, or unset", trust, WorkingDirectoryTrustSession, WorkingDirectoryTrustUser)
}
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
c.applyConfigDefaultsForMode(config)
registrationID := c.registerGitHubTokenProvider(config.GitHubTokenProvider)
registrationTransferred := false
defer func() {
if !registrationTransferred {
c.unregisterGitHubTokenProvider(registrationID)
}
}()
req := createSessionRequest{}
req.Model = config.Model
req.ClientName = config.ClientName
req.ReasoningEffort = config.ReasoningEffort
req.ReasoningSummary = config.ReasoningSummary
req.ContextTier = config.ContextTier
req.ConfigDir = config.ConfigDirectory
req.EnableConfigDiscovery = config.EnableConfigDiscovery
req.TrustWorkingDirectory = string(config.TrustWorkingDirectory)
req.SkipEmbeddingRetrieval = config.SkipEmbeddingRetrieval
req.EmbeddingCacheStorage = config.EmbeddingCacheStorage
req.OrganizationCustomInstructions = config.OrganizationCustomInstructions
req.EnableOnDemandInstructionDiscovery = config.EnableOnDemandInstructionDiscovery
req.EnableFileHooks = config.EnableFileHooks
req.EnableHostGitOperations = config.EnableHostGitOperations
req.EnableSessionStore = config.EnableSessionStore
req.EnableSkills = config.EnableSkills
if config.SkillProvider != nil {
req.HasSkillProvider = Bool(true)
}
req.Tools = config.Tools
systemMessage := c.systemMessageForMode(config.SystemMessage)
wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage)
req.SystemMessage = wireSystemMessage
availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools)
if ferr != nil {
return nil, ferr
}
req.AvailableTools = availableTools
req.ExcludedTools = excludedTools
req.ToolFilterPrecedence = precedence
req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents
req.Provider = config.Provider
req.Capi = config.Capi
req.Providers = config.Providers
req.Models = config.Models
req.AskUserVariant = config.AskUserVariant
req.EnableSessionTelemetry = config.EnableSessionTelemetry
req.EnableCitations = config.EnableCitations
req.EnableFileChangeTracking = config.EnableFileChangeTracking
req.SessionLimits = config.SessionLimits
req.Autopilot = config.Autopilot
req.IsExperimentalMode = config.EnableExperimentalMode
req.SkipCustomInstructions = config.SkipCustomInstructions
req.RefreshCustomInstructions = config.RefreshCustomInstructions
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
req.CoauthorEnabled = config.CoauthorEnabled
req.ManageScheduleEnabled = config.ManageScheduleEnabled
req.ModelCapabilities = config.ModelCapabilities
req.WorkingDirectory = config.WorkingDirectory
req.AdditionalDirectories = config.AdditionalDirectories
req.MCPServers = config.MCPServers
req.Diagnostics = config.Diagnostics
req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage
req.AuthClientIDMetadataURL = config.AuthClientIDMetadataURL
req.EnvValueMode = "direct"
req.CustomAgents = config.CustomAgents
req.DefaultAgent = config.DefaultAgent
req.Agent = config.Agent
req.SkillDirectories = config.SkillDirectories
req.AgentDirectories = config.AgentDirectories
req.HookDirectories = config.HookDirectories
req.PluginDirectories = config.PluginDirectories
req.InstructionDirectories = config.InstructionDirectories
req.DisabledSkills = config.DisabledSkills
if config.DisabledMCPServers != nil {
req.DisabledMCPServers = &config.DisabledMCPServers
}
req.SandboxConfig = config.SandboxConfig
req.InfiniteSessions = config.InfiniteSessions
req.LargeOutput = config.LargeOutput
req.ToolSearch = config.ToolSearch
req.ImageGeneration = config.ImageGeneration
req.Memory = config.Memory
req.GitHubToken = config.GitHubToken
req.GitHubTokenProviderRegistrationID = registrationID
req.Cloud = config.Cloud
req.Canvases = config.Canvases
req.ExtensionInfo = config.ExtensionInfo
req.CanvasProvider = config.CanvasProvider
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
req.ExtensionInfo = config.ExtensionInfo
req.ExpAssignments = config.ExpAssignments
if config.FeatureFlags != nil {
req.FeatureFlags = &config.FeatureFlags
}
req.EnableManagedSettings = config.EnableManagedSettings
req.ExcludedManagedSettings = config.ExcludedManagedSettings
req.EnforceManagedModelDefaults = config.EnforceManagedModelDefaults
req.InteractiveUser = config.InteractiveUser
req.ManagedSettings = config.ManagedSettings
if len(config.Commands) > 0 {
cmds := make([]wireCommand, 0, len(config.Commands))
for _, cmd := range config.Commands {
cmds = append(cmds, wireCommand{Name: cmd.Name, Description: cmd.Description})
}
req.Commands = cmds
}
if config.OnElicitationRequest != nil {
req.RequestElicitation = Bool(true)
}
if config.OnExitPlanModeRequest != nil {
req.RequestExitPlanMode = Bool(true)
}
if config.OnAutoModeSwitchRequest != nil {
req.RequestAutoModeSwitch = Bool(true)
}
if config.EnableMCPApps {
req.RequestMCPApps = Bool(true)
}
req.GitHubMCPToolConfig = config.GitHubMCPToolConfig
if config.Streaming != nil {
req.Streaming = config.Streaming
}
if config.IncludeSubAgentStreamingEvents != nil {
req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents
} else {
req.IncludeSubAgentStreamingEvents = Bool(true)
}
if c.options.OnGitHubTelemetry != nil {
req.EnableGitHubTelemetryForwarding = Bool(true)
}
if config.OnUserInputRequest != nil {
req.RequestUserInput = Bool(true)
}
if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil ||
config.Hooks.OnPreMCPToolCall != nil ||
config.Hooks.OnPostToolUse != nil ||
config.Hooks.OnPostToolUseFailure != nil ||
config.Hooks.OnUserPromptSubmitted != nil ||
config.Hooks.OnUserPromptTransformed != nil ||
config.Hooks.OnSessionStart != nil ||
config.Hooks.OnSessionEnd != nil ||
config.Hooks.OnErrorOccurred != nil ||
config.Hooks.OnAgentStop != nil ||
config.Hooks.OnSubagentStart != nil ||
config.Hooks.OnSubagentStop != nil) {
req.Hooks = Bool(true)
}
if config.OnPermissionRequest != nil || config.OnPermissionRequestWithPrompt != nil {
req.RequestPermission = Bool(true)
}
traceparent, tracestate := getTraceContext(ctx)
req.Traceparent = traceparent
req.Tracestate = tracestate
useServerGeneratedID := config.Cloud != nil && config.SessionID == ""
var localSessionID string
if useServerGeneratedID {
localSessionID = ""
} else if config.SessionID != "" {
localSessionID = config.SessionID
} else {
localSessionID = uuid.NewString()
}
req.SessionID = localSessionID
req.Name = config.Name
unregisterSession := func(sessionID string, s *Session) {
c.sessionsMux.Lock()
if c.sessions[sessionID] == s {
delete(c.sessions, sessionID)
}
c.sessionsMux.Unlock()
s.cancelPendingExternalTools()
s.stopEventProcessing()
}
initializeSession := func(sessionID string) (*Session, error) {
s := newSession(
sessionID,
c.client,
"",
hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings),
)
s.registerTools(config.Tools)
s.registerPermissionHandler(config.OnPermissionRequest)
s.registerPermissionHandlerWithPrompt(config.OnPermissionRequestWithPrompt)
s.registerMCPAuthHandlers(config.OnMCPAuthRequest, runtimeRefreshHandler)
if config.OnUserInputRequest != nil {
s.registerUserInputHandler(config.OnUserInputRequest)
}
if config.Hooks != nil {
s.registerHooks(config.Hooks)
}
if transformCallbacks != nil {
s.registerTransformCallbacks(transformCallbacks)
}
if config.OnEvent != nil {
s.On(config.OnEvent)
}
if len(config.Commands) > 0 {
s.registerCommands(config.Commands)
}
if config.OnElicitationRequest != nil {
s.registerElicitationHandler(config.OnElicitationRequest)
}
if config.OnExitPlanModeRequest != nil {
s.registerExitPlanModeHandler(config.OnExitPlanModeRequest)
}
if config.OnAutoModeSwitchRequest != nil {
s.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest)
}
if config.CanvasHandler != nil {
s.registerCanvasHandler(config.CanvasHandler)
}
if config.SkillProvider != nil {
s.registerSkillProvider(config.SkillProvider)
}
if bearerTokenProviders := collectBearerTokenProviders(config.Provider, config.Providers); bearerTokenProviders != nil {
s.registerBearerTokenProviders(bearerTokenProviders)
}
if c.options.SessionFS != nil {
if config.CreateSessionFSProvider == nil {
unregisterSession(sessionID, s)
return nil, fmt.Errorf("CreateSessionFSProvider is required in session config when SessionFS is enabled in client options")
}
provider := config.CreateSessionFSProvider(s)
if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Sqlite {
if _, ok := provider.(SessionFSSqliteProvider); !ok {
unregisterSession(sessionID, s)
return nil, fmt.Errorf("SessionFS capabilities declare SQLite support but the provider does not implement SessionFSSqliteProvider")
}
}
if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Binary {
if _, ok := provider.(SessionFSBinaryProvider); !ok {
unregisterSession(sessionID, s)
return nil, fmt.Errorf("SessionFS capabilities declare binary support but the provider does not implement SessionFSBinaryProvider")
}
}
s.clientSessionAPIs.SessionFS = newSessionFSAdapter(provider)
}
c.sessionsMux.Lock()
c.sessions[sessionID] = s
c.sessionsMux.Unlock()
return s, nil
}
var session *Session
var registeredSessionID string
var orphanedSessionID string
var inlineMu sync.Mutex
var requestFinished bool
deleteOrphan := func(sessionID string) error {
cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancelCleanup()
return c.DeleteSession(cleanupCtx, sessionID)
}
if localSessionID != "" {
s, err := initializeSession(localSessionID)
if err != nil {
return nil, err
}
session = s
registeredSessionID = localSessionID
}
var inlineCb func(raw json.RawMessage) error
if session == nil {
inlineCb = func(raw json.RawMessage) error {
var early struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(raw, &early); err != nil {
return fmt.Errorf("failed to parse sessionId from response: %w", err)
}
if early.SessionID == "" {
return fmt.Errorf("session.create response did not include a sessionId")
}
s, err := initializeSession(early.SessionID)
inlineMu.Lock()
if err != nil {
if requestFinished {
go func() {
if cleanupErr := deleteOrphan(early.SessionID); cleanupErr != nil {
log.Printf("failed to delete uninitialized cloud session %s: %v", early.SessionID, cleanupErr)
}
}()
} else {
orphanedSessionID = early.SessionID
}
inlineMu.Unlock()
return err
}
if requestFinished {
inlineMu.Unlock()
unregisterSession(early.SessionID, s)
return nil
}
session = s
registeredSessionID = early.SessionID
inlineMu.Unlock()
return nil
}
}
result, err := c.client.RequestWithInlineResponse(ctx, "session.create", req, inlineCb)
inlineMu.Lock()
requestFinished = true
cleanupSessionID := orphanedSessionID
sessionToUnregister := session
registeredID := registeredSessionID
inlineMu.Unlock()
if err != nil {
if registeredID != "" {
unregisterSession(registeredID, sessionToUnregister)
}
if cleanupSessionID != "" {
if cleanupErr := deleteOrphan(cleanupSessionID); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("failed to delete uninitialized cloud session: %w", cleanupErr))
}
}
return nil, fmt.Errorf("failed to create session: %w", err)
}
var response createSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
if registeredSessionID != "" {
unregisterSession(registeredSessionID, session)
}
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if session == nil {
return nil, fmt.Errorf("session.create response did not include a sessionId")
}
if localSessionID != "" && response.SessionID != "" && response.SessionID != localSessionID {
unregisterSession(registeredSessionID, session)
return nil, fmt.Errorf("session.create returned sessionId %s but the caller requested %s", response.SessionID, localSessionID)
}
if config.OnMCPAuthRequest != nil || runtimeRefreshHandler != nil {
if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{
"sessionId": session.SessionID,
"eventType": "mcp.oauth_required",
}); err != nil {
unregisterSession(registeredSessionID, session)
return nil, err
}
}
session.workspacePath = response.WorkspacePath
session.setCapabilities(response.Capabilities)
if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{
SkipCustomInstructions: config.SkipCustomInstructions,
CustomAgentsLocalOnly: config.CustomAgentsLocalOnly,
CoauthorEnabled: config.CoauthorEnabled,
ManageScheduleEnabled: config.ManageScheduleEnabled,
IncludedBuiltinSkills: config.IncludedBuiltinSkills,
}); err != nil {
unregisterSession(registeredSessionID, session)
return nil, err
}
if err := c.captureAhpSession(session, req); err != nil {
unregisterSession(registeredSessionID, session)
return nil, err
}
if registrationID != "" {
session.setGitHubTokenProviderRegistrationRelease(func() {
c.unregisterGitHubTokenProvider(registrationID)
})
registrationTransferred = true
}
return session, nil
}
func (c *Client) ResumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) {
return c.ResumeSessionWithOptions(ctx, sessionID, config)
}
func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) {
return c.resumeSession(ctx, sessionID, config, nil)
}
func (c *Client) ResumeSessionWithMCPAuthRuntimeRefresh(ctx context.Context, sessionID string, config *ResumeSessionConfig, handler MCPAuthRuntimeRefreshHandler) (*Session, error) {
return c.resumeSession(ctx, sessionID, config, handler)
}
func (c *Client) resumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig, runtimeRefreshHandler MCPAuthRuntimeRefreshHandler) (*Session, error) {
unlockSession := c.lockSessionOperation(sessionID)
defer unlockSession()
if config == nil {
config = &ResumeSessionConfig{}
}
if config.GitHubToken != "" && config.GitHubTokenProvider != nil {
return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together")
}
if err := validateAskUserVariant(config.AskUserVariant); err != nil {
return nil, err
}
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
c.applyResumeDefaultsForMode(config)
registrationID := c.registerGitHubTokenProvider(config.GitHubTokenProvider)
registrationTransferred := false
defer func() {
if !registrationTransferred {
c.unregisterGitHubTokenProvider(registrationID)
}
}()
var req resumeSessionRequest
req.SessionID = sessionID
req.ClientName = config.ClientName
req.Model = config.Model
req.ReasoningEffort = config.ReasoningEffort
req.ReasoningSummary = config.ReasoningSummary
req.ContextTier = config.ContextTier
systemMessage := c.systemMessageForMode(config.SystemMessage)
wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage)
req.SystemMessage = wireSystemMessage
req.Tools = config.Tools
req.Provider = config.Provider
req.Capi = config.Capi
req.Providers = config.Providers
req.Models = config.Models
req.AskUserVariant = config.AskUserVariant
req.EnableSessionTelemetry = config.EnableSessionTelemetry
req.IsExperimentalMode = config.EnableExperimentalMode
req.SkipCustomInstructions = config.SkipCustomInstructions
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
req.CoauthorEnabled = config.CoauthorEnabled
req.ManageScheduleEnabled = config.ManageScheduleEnabled
req.ModelCapabilities = config.ModelCapabilities
availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools)
if ferr != nil {
return nil, ferr
}
req.AvailableTools = availableTools
req.ExcludedTools = excludedTools
req.ToolFilterPrecedence = precedence
req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents
req.EnableCitations = config.EnableCitations
req.EnableFileChangeTracking = config.EnableFileChangeTracking
req.SessionLimits = config.SessionLimits
req.Autopilot = config.Autopilot
if config.Streaming != nil {
req.Streaming = config.Streaming
}
if config.IncludeSubAgentStreamingEvents != nil {
req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents
} else {
req.IncludeSubAgentStreamingEvents = Bool(true)
}
if c.options.OnGitHubTelemetry != nil {
req.EnableGitHubTelemetryForwarding = Bool(true)
}
if config.OnUserInputRequest != nil {
req.RequestUserInput = Bool(true)
}
if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil ||
config.Hooks.OnPreMCPToolCall != nil ||
config.Hooks.OnPostToolUse != nil ||
config.Hooks.OnPostToolUseFailure != nil ||
config.Hooks.OnUserPromptSubmitted != nil ||
config.Hooks.OnUserPromptTransformed != nil ||
config.Hooks.OnSessionStart != nil ||
config.Hooks.OnSessionEnd != nil ||
config.Hooks.OnErrorOccurred != nil ||
config.Hooks.OnAgentStop != nil ||
config.Hooks.OnSubagentStart != nil ||
config.Hooks.OnSubagentStop != nil) {
req.Hooks = Bool(true)
}
req.WorkingDirectory = config.WorkingDirectory
req.AdditionalDirectories = config.AdditionalDirectories
req.ConfigDir = config.ConfigDirectory
req.EnableConfigDiscovery = config.EnableConfigDiscovery
req.SkipEmbeddingRetrieval = config.SkipEmbeddingRetrieval
req.EmbeddingCacheStorage = config.EmbeddingCacheStorage
req.OrganizationCustomInstructions = config.OrganizationCustomInstructions
req.EnableOnDemandInstructionDiscovery = config.EnableOnDemandInstructionDiscovery
req.EnableFileHooks = config.EnableFileHooks
req.EnableHostGitOperations = config.EnableHostGitOperations
req.EnableSessionStore = config.EnableSessionStore
req.EnableSkills = config.EnableSkills
if config.SkillProvider != nil {
req.HasSkillProvider = Bool(true)
}
if config.SuppressResumeEvent {
req.DisableResume = Bool(true)
}
req.ContinuePendingWork = config.ContinuePendingWork
req.AllowTranscriptRecovery = config.AllowTranscriptRecovery
req.MCPServers = config.MCPServers
req.Diagnostics = config.Diagnostics
req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage
req.AuthClientIDMetadataURL = config.AuthClientIDMetadataURL
req.EnvValueMode = "direct"
req.CustomAgents = config.CustomAgents
req.DefaultAgent = config.DefaultAgent
req.Agent = config.Agent
req.SkillDirectories = config.SkillDirectories
req.AgentDirectories = config.AgentDirectories
req.HookDirectories = config.HookDirectories
req.PluginDirectories = config.PluginDirectories
req.InstructionDirectories = config.InstructionDirectories
req.DisabledSkills = config.DisabledSkills
if config.DisabledMCPServers != nil {
req.DisabledMCPServers = &config.DisabledMCPServers
}
req.SandboxConfig = config.SandboxConfig
req.InfiniteSessions = config.InfiniteSessions
req.LargeOutput = config.LargeOutput
req.ToolSearch = config.ToolSearch
req.ImageGeneration = config.ImageGeneration
req.Memory = config.Memory
req.GitHubToken = config.GitHubToken
req.GitHubTokenProviderRegistrationID = registrationID
req.Canvases = config.Canvases
req.OpenCanvases = config.OpenCanvases
req.ExtensionInfo = config.ExtensionInfo
req.CanvasProvider = config.CanvasProvider
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
req.ExtensionInfo = config.ExtensionInfo
req.ExpAssignments = config.ExpAssignments
if config.FeatureFlags != nil {
req.FeatureFlags = &config.FeatureFlags
}
req.EnableManagedSettings = config.EnableManagedSettings
req.ExcludedManagedSettings = config.ExcludedManagedSettings
req.EnforceManagedModelDefaults = config.EnforceManagedModelDefaults
req.InteractiveUser = config.InteractiveUser
req.ManagedSettings = config.ManagedSettings
if config.OnPermissionRequest != nil || config.OnPermissionRequestWithPrompt != nil {
req.RequestPermission = Bool(true)
}
if len(config.Commands) > 0 {
cmds := make([]wireCommand, 0, len(config.Commands))
for _, cmd := range config.Commands {
cmds = append(cmds, wireCommand{Name: cmd.Name, Description: cmd.Description})
}
req.Commands = cmds
}
if config.OnElicitationRequest != nil {
req.RequestElicitation = Bool(true)
}
if config.OnExitPlanModeRequest != nil {
req.RequestExitPlanMode = Bool(true)
}
if config.OnAutoModeSwitchRequest != nil {
req.RequestAutoModeSwitch = Bool(true)
}
if config.EnableMCPApps {
req.RequestMCPApps = Bool(true)
}
req.GitHubMCPToolConfig = config.GitHubMCPToolConfig
traceparent, tracestate := getTraceContext(ctx)
req.Traceparent = traceparent
req.Tracestate = tracestate
session := newSession(
sessionID,
c.client,
"",
hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings),
)
session.registerTools(config.Tools)
session.registerPermissionHandler(config.OnPermissionRequest)
session.registerPermissionHandlerWithPrompt(config.OnPermissionRequestWithPrompt)
session.registerMCPAuthHandlers(config.OnMCPAuthRequest, runtimeRefreshHandler)
if config.OnUserInputRequest != nil {
session.registerUserInputHandler(config.OnUserInputRequest)
}
if config.Hooks != nil {
session.registerHooks(config.Hooks)
}
if transformCallbacks != nil {
session.registerTransformCallbacks(transformCallbacks)
}
if config.OnEvent != nil {
session.On(config.OnEvent)
}
if len(config.Commands) > 0 {
session.registerCommands(config.Commands)
}
if config.OnElicitationRequest != nil {
session.registerElicitationHandler(config.OnElicitationRequest)
}
if config.OnExitPlanModeRequest != nil {
session.registerExitPlanModeHandler(config.OnExitPlanModeRequest)
}
if config.OnAutoModeSwitchRequest != nil {
session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest)
}
if config.CanvasHandler != nil {
session.registerCanvasHandler(config.CanvasHandler)
}
if config.SkillProvider != nil {
session.registerSkillProvider(config.SkillProvider)
}
if bearerTokenProviders := collectBearerTokenProviders(config.Provider, config.Providers); bearerTokenProviders != nil {
session.registerBearerTokenProviders(bearerTokenProviders)
}
if c.options.SessionFS != nil {
if config.CreateSessionFSProvider == nil {
session.stopEventProcessing()
return nil, fmt.Errorf("CreateSessionFSProvider is required in session config when SessionFS is enabled in client options")
}
provider := config.CreateSessionFSProvider(session)
if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Sqlite {
if _, ok := provider.(SessionFSSqliteProvider); !ok {
session.stopEventProcessing()
return nil, fmt.Errorf("SessionFS capabilities declare SQLite support but the provider does not implement SessionFSSqliteProvider")
}
}
if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Binary {
if _, ok := provider.(SessionFSBinaryProvider); !ok {
session.stopEventProcessing()
return nil, fmt.Errorf("SessionFS capabilities declare binary support but the provider does not implement SessionFSBinaryProvider")
}
}
session.clientSessionAPIs.SessionFS = newSessionFSAdapter(provider)
}
c.sessionsMux.Lock()
replacedSession := c.sessions[sessionID]
c.sessions[sessionID] = session
c.sessionsMux.Unlock()
restoreReplacedSession := func() {
c.sessionsMux.Lock()
if current := c.sessions[sessionID]; current == nil || current == session {
if replacedSession != nil {
c.sessions[sessionID] = replacedSession
} else {
delete(c.sessions, sessionID)
}
}
c.sessionsMux.Unlock()
session.cancelPendingExternalTools()
session.stopEventProcessing()
}
result, err := c.client.Request(ctx, "session.resume", req)
if err != nil {
restoreReplacedSession()
return nil, fmt.Errorf("failed to resume session: %w", err)
}
var response resumeSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
restoreReplacedSession()
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if config.OnMCPAuthRequest != nil || runtimeRefreshHandler != nil {
if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{
"sessionId": sessionID,
"eventType": "mcp.oauth_required",
}); err != nil {
restoreReplacedSession()
return nil, err
}
}
session.workspacePath = response.WorkspacePath
session.transcriptRecovery = response.TranscriptRecovery
session.setCapabilities(response.Capabilities)
session.setOpenCanvases(response.OpenCanvases)
if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{
SkipCustomInstructions: config.SkipCustomInstructions,
CustomAgentsLocalOnly: config.CustomAgentsLocalOnly,
CoauthorEnabled: config.CoauthorEnabled,
ManageScheduleEnabled: config.ManageScheduleEnabled,
IncludedBuiltinSkills: config.IncludedBuiltinSkills,
}); err != nil {
restoreReplacedSession()
return nil, err
}
if err := c.captureAhpSession(session, req); err != nil {
restoreReplacedSession()
return nil, err
}
if registrationID != "" {
session.setGitHubTokenProviderRegistrationRelease(func() {
c.unregisterGitHubTokenProvider(registrationID)
})
registrationTransferred = true
}
if replacedSession != nil && replacedSession != session {
replacedSession.releaseGitHubTokenProviderRegistration()
}
return session, nil
}
func (c *Client) ListSessions(ctx context.Context, filter *SessionListFilter) ([]SessionMetadata, error) {
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
params := listSessionsRequest{}
if filter != nil {
params.Filter = filter
}
result, err := c.client.Request(ctx, "session.list", params)
if err != nil {
return nil, err
}
var response listSessionsResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal sessions response: %w", err)
}
return response.Sessions, nil
}
func (c *Client) GetSessionMetadata(ctx context.Context, sessionID string) (*SessionMetadata, error) {
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
result, err := c.client.Request(ctx, "session.getMetadata", getSessionMetadataRequest{SessionID: sessionID})
if err != nil {
return nil, err
}
var response getSessionMetadataResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal session metadata response: %w", err)
}
return response.Session, nil
}
func (c *Client) DeleteSession(ctx context.Context, sessionID string) error {
unlockSession := c.lockSessionOperation(sessionID)
defer unlockSession()
if err := c.ensureConnected(ctx); err != nil {
return err
}
result, err := c.client.Request(ctx, "session.delete", deleteSessionRequest{SessionID: sessionID})
if err != nil {
return err
}
var response deleteSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
return fmt.Errorf("failed to unmarshal delete response: %w", err)
}
if !response.Success {
errorMsg := "unknown error"
if response.Error != nil {
errorMsg = *response.Error
}
return fmt.Errorf("failed to delete session %s: %s", sessionID, errorMsg)
}
c.sessionsMux.Lock()
session := c.sessions[sessionID]
delete(c.sessions, sessionID)
c.sessionsMux.Unlock()
if session != nil {
session.clearSkillProvider()
session.releaseGitHubTokenProviderRegistration()
}
return nil
}
func (c *Client) GetLastSessionID(ctx context.Context) (*string, error) {
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
result, err := c.client.Request(ctx, "session.getLastId", getLastSessionIDRequest{})
if err != nil {
return nil, err
}
var response getLastSessionIDResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal getLastId response: %w", err)
}
return response.SessionID, nil
}
func (c *Client) GetForegroundSessionID(ctx context.Context) (*string, error) {
if err := c.ensureConnected(ctx); err != nil {
return nil, err
}
result, err := c.client.Request(ctx, "session.getForeground", getForegroundSessionRequest{})
if err != nil {
return nil, err
}
var response getForegroundSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal getForeground response: %w", err)
}
return response.SessionID, nil
}
func (c *Client) SetForegroundSessionID(ctx context.Context, sessionID string) error {
if err := c.ensureConnected(ctx); err != nil {
return err
}
result, err := c.client.Request(ctx, "session.setForeground", setForegroundSessionRequest{SessionID: sessionID})
if err != nil {
return err
}
var response setForegroundSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
return fmt.Errorf("failed to unmarshal setForeground response: %w", err)
}
if !response.Success {
errorMsg := "unknown error"
if response.Error != nil {
errorMsg = *response.Error
}
return fmt.Errorf("failed to set foreground session: %s", errorMsg)
}
return nil
}
func (c *Client) On(handler SessionLifecycleHandler) func() {
c.lifecycleHandlersMux.Lock()
if c.lifecycleHandlers == nil {
c.lifecycleHandlers = make(map[uint64]SessionLifecycleHandler)
}
c.nextLifecycleHandlerID++
id := c.nextLifecycleHandlerID
c.lifecycleHandlers[id] = handler
c.lifecycleHandlersMux.Unlock()
return func() {
c.lifecycleHandlersMux.Lock()
defer c.lifecycleHandlersMux.Unlock()
delete(c.lifecycleHandlers, id)
}
}
func (c *Client) OnEventType(eventType SessionLifecycleEventType, handler SessionLifecycleHandler) func() {
c.lifecycleHandlersMux.Lock()
if c.typedLifecycleHandlers == nil {
c.typedLifecycleHandlers = make(map[SessionLifecycleEventType]map[uint64]SessionLifecycleHandler)
}
if c.typedLifecycleHandlers[eventType] == nil {
c.typedLifecycleHandlers[eventType] = make(map[uint64]SessionLifecycleHandler)
}
c.nextLifecycleHandlerID++
id := c.nextLifecycleHandlerID
c.typedLifecycleHandlers[eventType][id] = handler
c.lifecycleHandlersMux.Unlock()
return func() {
c.lifecycleHandlersMux.Lock()
defer c.lifecycleHandlersMux.Unlock()
if handlers, ok := c.typedLifecycleHandlers[eventType]; ok {
delete(handlers, id)
}
}
}
func (c *Client) handleLifecycleEvent(event SessionLifecycleEvent) {
c.lifecycleHandlersMux.Lock()
typedHandlers := make([]SessionLifecycleHandler, 0)
if handlers, ok := c.typedLifecycleHandlers[event.Type]; ok {
for _, handler := range handlers {
typedHandlers = append(typedHandlers, handler)
}
}
wildcardHandlers := make([]SessionLifecycleHandler, 0, len(c.lifecycleHandlers))
for _, handler := range c.lifecycleHandlers {
wildcardHandlers = append(wildcardHandlers, handler)
}
c.lifecycleHandlersMux.Unlock()
for _, handler := range typedHandlers {
func() {
defer func() { recover() }()
handler(event)
}()
}
for _, handler := range wildcardHandlers {
func() {
defer func() { recover() }()
handler(event)
}()
}
}
func (c *Client) RuntimePort() int {
return c.actualPort
}
func (c *Client) Ping(ctx context.Context, message string) (*PingResponse, error) {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
result, err := c.client.Request(ctx, "ping", pingRequest{Message: message})
if err != nil {
return nil, err
}
var response PingResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, err
}
return &response, nil
}
func (c *Client) GetStatus(ctx context.Context) (*GetStatusResponse, error) {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
result, err := c.client.Request(ctx, "status.get", getStatusRequest{})
if err != nil {
return nil, err
}
var response GetStatusResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, err
}
return &response, nil
}
func (c *Client) GetAuthStatus(ctx context.Context) (*GetAuthStatusResponse, error) {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
result, err := c.client.Request(ctx, "auth.getStatus", getAuthStatusRequest{})
if err != nil {
return nil, err
}
var response GetAuthStatusResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, err
}
return &response, nil
}
func (c *Client) ListModels(ctx context.Context) ([]ModelInfo, error) {
c.modelsCacheMux.Lock()
defer c.modelsCacheMux.Unlock()
if c.modelsCache != nil {
result := make([]ModelInfo, len(c.modelsCache))
copy(result, c.modelsCache)
return result, nil
}
var models []ModelInfo
if c.onListModels != nil {
var err error
models, err = c.onListModels(ctx)
if err != nil {
return nil, err
}
} else {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
result, err := c.client.Request(ctx, "models.list", listModelsRequest{})
if err != nil {
return nil, err
}
var response listModelsResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal models response: %w", err)
}
models = response.Models
}
cache := make([]ModelInfo, len(models))
copy(cache, models)
c.modelsCache = cache
result := make([]ModelInfo, len(models))
copy(result, models)
return result, nil
}
const minProtocolVersion = 3
const runtimeShutdownTimeout = 10 * time.Second
const processExitTimeout = 10 * time.Second
func (c *Client) verifyProtocolVersion(ctx context.Context) error {
if c.client == nil {
return fmt.Errorf("client not connected")
}
maxVersion := GetSDKProtocolVersion()
var serverVersion *int
tokenPtr := (*string)(nil)
if c.effectiveConnectionToken != "" {
t := c.effectiveConnectionToken
tokenPtr = &t
}
connectReq := &connectHandshakeRequest{
Entra: c.options.Entra,
Token: tokenPtr,
SupportedTaskKinds: []rpc.TaskKind{
rpc.TaskKindAgent,
rpc.TaskKindClient,
rpc.TaskKindShell,
},
}
if c.options.OnGitHubTelemetry != nil {
connectReq.EnableGitHubTelemetryForwarding = Bool(true)
}
connectReq.ClientInfo = c.options.ClientInfo.toWire()
rawConnectResult, err := c.client.Request(ctx, "connect", connectReq)
if err != nil {
var rpcErr *jsonrpc2.Error
if errors.As(err, &rpcErr) && (rpcErr.Code == jsonrpc2.ErrMethodNotFound.Code || rpcErr.Message == "Unhandled method connect") {
if c.options.Entra != nil {
return fmt.Errorf("this runtime does not support Entra client configuration: %w", err)
}
pingResult, perr := c.Ping(ctx, "")
if perr != nil {
return perr
}
serverVersion = pingResult.ProtocolVersion
} else {
return err
}
} else {
var connectResult rpc.ConnectResult
if err := json.Unmarshal(rawConnectResult, &connectResult); err != nil {
return err
}
v := int(connectResult.ProtocolVersion)
serverVersion = &v
}
if serverVersion == nil {
return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server does not report a protocol version. Please update your server to ensure compatibility", minProtocolVersion, maxVersion)
}
if *serverVersion < minProtocolVersion || *serverVersion > maxVersion {
return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server reports version %d. Please update your SDK or server to ensure compatibility", minProtocolVersion, maxVersion, *serverVersion)
}
c.negotiatedProtocolVersion = *serverVersion
return nil
}
type connectHandshakeRequest struct {
Entra *rpc.EntraAuthConfig `json:"entra,omitempty"`
Token *string `json:"token,omitempty"`
EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"`
ClientInfo *rpc.ConnectClientInfo `json:"clientInfo,omitempty"`
SupportedTaskKinds []rpc.TaskKind `json:"supportedTaskKinds,omitempty"`
}
const stderrBufferSize = 64 * 1024
func (c *Client) startCLIServer(ctx context.Context) error {
if c.useInProcess {
return c.startInProcess(ctx)
}
bundledRuntimePath := ""
if c.cliPath == "" {
bundledRuntimePath = embeddedcli.RuntimePath()
}
cliPath, err := resolveRuntimeExecutable(c.cliPath, bundledRuntimePath)
if err != nil {
return err
}
args := append([]string{}, c.cliArgs...)
args = append(args, "--headless", "--no-auto-update")
if c.options.LogLevel != "" {
args = append(args, "--log-level", c.options.LogLevel)
}
if c.useStdio {
args = append(args, "--stdio")
} else if c.port > 0 {
args = append(args, "--port", strconv.Itoa(c.port))
}
if c.options.GitHubToken != "" {
args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN")
}
useLoggedInUser := true
if c.options.UseLoggedInUser != nil {
useLoggedInUser = *c.options.UseLoggedInUser
} else if c.options.GitHubToken != "" {
useLoggedInUser = false
}
if !useLoggedInUser {
args = append(args, "--no-auto-login")
}
if c.options.SessionIdleTimeoutSeconds > 0 {
args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds))
}
command := cliPath
if strings.HasSuffix(cliPath, ".js") {
command = "node"
args = append([]string{cliPath}, args...)
}
c.process = exec.Command(command, args...)
configureProcAttr(c.process)
if c.options.WorkingDirectory != "" {
c.process.Dir = c.options.WorkingDirectory
}
c.process.Env = append([]string{}, c.options.Env...)
if c.options.GitHubToken != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_SDK_AUTH_TOKEN", c.options.GitHubToken)
}
if c.effectiveConnectionToken != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_CONNECTION_TOKEN", c.effectiveConnectionToken)
}
if c.options.BaseDirectory != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.BaseDirectory)
}
if c.options.Mode == ModeEmpty {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_DISABLE_KEYTAR", "1")
}
if c.options.Mode != ModeEmpty {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_RUNTIME_PROCESS_FILE_LOGGING", "1")
}
if c.options.Telemetry != nil {
t := c.options.Telemetry
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_ENABLED", "true")
if t.OTLPEndpoint != "" {
c.process.Env = setEnvValue(c.process.Env, "OTEL_EXPORTER_OTLP_ENDPOINT", t.OTLPEndpoint)
}
if t.OTLPProtocol != "" {
c.process.Env = setEnvValue(c.process.Env, "OTEL_EXPORTER_OTLP_PROTOCOL", t.OTLPProtocol)
}
if t.FilePath != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_FILE_EXPORTER_PATH", t.FilePath)
}
if t.ExporterType != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_EXPORTER_TYPE", t.ExporterType)
}
if t.SourceName != "" {
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_SOURCE_NAME", t.SourceName)
}
if t.CaptureContent != nil {
val := "false"
if *t.CaptureContent {
val = "true"
}
c.process.Env = setEnvValue(c.process.Env, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", val)
}
}
if c.useStdio {
stdin, err := c.process.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
stdout, err := c.process.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
if err := c.process.Start(); err != nil {
return fmt.Errorf("failed to start CLI server: %w", err)
}
c.processStdin = stdin
c.monitorProcess()
c.client = jsonrpc2.NewClient(stdin, stdout)
c.client.SetProcessDone(c.processDone, c.processErrorPtr)
c.client.SetOnClose(c.handleConnectionClose)
c.RPC = rpc.NewServerRPC(c.client)
c.internalRPC = rpc.NewInternalServerRPC(c.client)
c.setupNotificationHandler()
c.client.Start()
return nil
} else {
stdout, err := c.process.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
if err := c.process.Start(); err != nil {
return fmt.Errorf("failed to start CLI server: %w", err)
}
c.monitorProcess()
proc := c.process
scanner := bufio.NewScanner(stdout)
portRegex := regexp.MustCompile(`listening on port (\d+)`)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
for {
select {
case <-ctx.Done():
killErr := c.killProcess()
baseErr := fmt.Errorf("failed waiting for CLI server to start: %w", ctx.Err())
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
if stderr := strings.TrimSpace(buf.String()); stderr != "" {
baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr)
}
}
return errors.Join(baseErr, killErr)
case <-c.processDone:
killErr := c.killProcess()
baseErr := errors.New("CLI server process exited before reporting port")
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
if stderr := strings.TrimSpace(buf.String()); stderr != "" {
baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr)
}
}
return errors.Join(baseErr, killErr)
default:
if scanner.Scan() {
line := scanner.Text()
if matches := portRegex.FindStringSubmatch(line); len(matches) > 1 {
port, err := strconv.Atoi(matches[1])
if err != nil {
killErr := c.killProcess()
return errors.Join(fmt.Errorf("failed to parse port: %w", err), killErr)
}
c.actualPort = port
return nil
}
}
}
}
}
}
func (c *Client) startInProcess(ctx context.Context) error {
if !inProcessAvailable {
return errors.New("in-process transport unavailable: rebuild with -tags copilot_inprocess on a supported platform")
}
cliEntrypoint := c.cliPath
if cliEntrypoint == "" {
cliEntrypoint = getEnvValue(c.options.Env, "COPILOT_CLI_PATH")
}
runtimePath := cliEntrypoint
if runtimePath == "" {
runtimePath = embeddedcli.RuntimePath()
}
if runtimePath == "" {
return errors.New("in-process runtime unavailable: build with the bundled embedded runtime or set COPILOT_CLI_PATH to a compatible runtime package")
}
integrationID := strings.TrimSpace(getEnvValue(c.options.Env, "GITHUB_COPILOT_INTEGRATION_ID"))
config := c.inProcessHostConfig(integrationID)
host, err := createInProcessHost(runtimePath, cliEntrypoint, config)
if err != nil {
return err
}
c.ffiHost = host
errCh := make(chan error, 1)
go func() { errCh <- host.Start() }()
select {
case err := <-errCh:
if err != nil {
host.Dispose()
c.ffiHost = nil
return err
}
case <-ctx.Done():
c.ffiHost = nil
go func() {
<-errCh
host.Dispose()
}()
return ctx.Err()
}
c.client = jsonrpc2.NewClient(host.Writer(), host.Reader())
c.client.SetOnClose(c.handleConnectionClose)
c.RPC = rpc.NewServerRPC(c.client)
c.internalRPC = rpc.NewInternalServerRPC(c.client)
c.setupNotificationHandler()
c.client.Start()
return host.ConfigureUiThread(c.runOnUiThread != nil)
}
func (c *Client) inProcessHostConfig(cliLoginSyncIntegrationID string) inProcessHostConfig {
args := make([]string, 0, 8)
if c.options.LogLevel != "" {
args = append(args, "--log-level", c.options.LogLevel)
}
if c.options.GitHubToken != "" {
args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN")
}
useLoggedInUser := true
if c.options.UseLoggedInUser != nil {
useLoggedInUser = *c.options.UseLoggedInUser
} else if c.options.GitHubToken != "" {
useLoggedInUser = false
}
if !useLoggedInUser {
args = append(args, "--no-auto-login")
}
if c.options.SessionIdleTimeoutSeconds > 0 {
args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds))
}
if cliLoginSyncIntegrationID != "" {
args = append(args, "--cli-login-sync-integration-id="+cliLoginSyncIntegrationID)
}
environment := make(map[string]string)
if c.options.GitHubToken != "" {
environment["COPILOT_SDK_AUTH_TOKEN"] = c.options.GitHubToken
}
if c.options.BaseDirectory != "" {
environment["COPILOT_HOME"] = c.options.BaseDirectory
}
if c.options.Mode == ModeEmpty {
environment["COPILOT_DISABLE_KEYTAR"] = "1"
}
return inProcessHostConfig{
Environment: environment,
Args: args,
}
}
func (c *Client) killProcess() error {
if c.ffiHost != nil {
c.ffiHost.Dispose()
c.ffiHost = nil
}
if p := c.osProcess.Swap(nil); p != nil {
if err := p.Kill(); err != nil {
return fmt.Errorf("failed to kill CLI process: %w", err)
}
}
if c.processStdin != nil {
_ = c.processStdin.Close()
c.processStdin = nil
}
c.process = nil
return nil
}
func (c *Client) killProcessAndWait() error {
done := c.processDone
killErr := c.killProcess()
if done == nil {
return killErr
}
select {
case <-done:
return killErr
case <-time.After(processExitTimeout):
return errors.Join(killErr, fmt.Errorf("timed out waiting for CLI process to exit after kill"))
}
}
func (c *Client) monitorProcess() {
done := make(chan struct{})
c.processDone = done
proc := c.process
c.osProcess.Store(proc.Process)
var processError error
c.processErrorPtr = &processError
go func() {
waitErr := proc.Wait()
var stderrOutput string
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
stderrOutput = strings.TrimSpace(buf.String())
}
if waitErr != nil {
if stderrOutput != "" {
processError = fmt.Errorf("CLI process exited: %w\nstderr: %s", waitErr, stderrOutput)
} else {
processError = fmt.Errorf("CLI process exited: %w", waitErr)
}
} else {
if stderrOutput != "" {
processError = fmt.Errorf("CLI process exited unexpectedly\nstderr: %s", stderrOutput)
} else {
processError = errors.New("CLI process exited unexpectedly")
}
}
close(done)
}()
}
func (c *Client) connectToServer(ctx context.Context) error {
if c.useStdio || c.useInProcess {
return nil
}
return c.connectViaTCP(ctx)
}
func (c *Client) connectViaTCP(ctx context.Context) error {
if c.actualPort == 0 {
return fmt.Errorf("server port not available")
}
address := net.JoinHostPort(c.actualHost, fmt.Sprintf("%d", c.actualPort))
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var dialer net.Dialer
conn, err := dialer.DialContext(dialCtx, "tcp", address)
if err != nil {
return fmt.Errorf("failed to connect to CLI server at %s: %w", address, err)
}
c.conn = conn
c.client = jsonrpc2.NewClient(conn, conn)
if c.processDone != nil {
c.client.SetProcessDone(c.processDone, c.processErrorPtr)
}
c.client.SetOnClose(c.handleConnectionClose)
c.RPC = rpc.NewServerRPC(c.client)
c.internalRPC = rpc.NewInternalServerRPC(c.client)
c.setupNotificationHandler()
c.client.Start()
return nil
}
func (c *Client) setupNotificationHandler() {
c.client.SetRequestHandler("session.event", jsonrpc2.NotificationHandlerFor(c.handleSessionEvent))
c.client.SetRequestHandler("session.lifecycle", jsonrpc2.NotificationHandlerFor(c.handleLifecycleEvent))
c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest))
c.client.SetRequestHandler("exitPlanMode.request", jsonrpc2.RequestHandlerFor(c.handleExitPlanModeRequest))
c.client.SetRequestHandler("autoModeSwitch.request", jsonrpc2.RequestHandlerFor(c.handleAutoModeSwitchRequest))
c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform))
c.client.SetRequestContextHandler("skillProvider.list", c.handleSkillProviderList)
c.client.SetRequestContextHandler("skillProvider.read", c.handleSkillProviderRead)
rpc.RegisterClientSessionAPIHandlers(c.client, func(sessionID string) *rpc.ClientSessionAPIHandlers {
c.sessionsMux.Lock()
defer c.sessionsMux.Unlock()
session := c.sessions[sessionID]
if session == nil {
return nil
}
return session.clientSessionAPIs
})
handlers := &rpc.ClientGlobalAPIHandlers{
ExtensionLaunchProvider: c.options.ExtensionLaunchProvider,
Hooks: &hooksAdapter{client: c},
GitHubToken: &gitHubTokenAdapter{client: c},
UIThread: &uiThreadAdapter{host: c.ffiHost, runOnUiThread: c.runOnUiThread},
}
if c.options.RequestHandler != nil {
llmInference := c.RPC.LlmInference
adapter := newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI {
return llmInference
})
c.requestAdapterMux.Lock()
previous := c.requestAdapter
c.requestAdapter = adapter
c.requestAdapterMux.Unlock()
if previous != nil {
previous.close()
}
handlers.LlmInference = adapter
}
if c.options.OnGitHubTelemetry != nil {
handlers.GitHubTelemetry = &gitHubTelemetryAdapter{callback: c.options.OnGitHubTelemetry}
}
rpc.RegisterClientGlobalAPIHandlers(c.client, handlers)
if c.options.InstallationConfirmationHandler != nil {
adapter := newInstallationConfirmationAdapter(c.options.InstallationConfirmationHandler, c.client.ConnectionClosed())
c.installationConfirmationAdapterMux.Lock()
previous := c.installationConfirmationAdapter
c.installationConfirmationAdapter = adapter
c.installationConfirmationAdapterMux.Unlock()
if previous != nil {
previous.close()
}
c.client.SetRequestContextHandler("installations.confirm", adapter.handle)
}
c.client.SetRequestHandler("host.materializeSession", jsonrpc2.RequestHandlerFor(c.materializeAhpSession))
c.client.SetRequestHandlerWithPreparation("host.validateAuthentication", jsonrpc2.RequestHandlerFor(c.validateAhpAuthentication), func(raw json.RawMessage) *jsonrpc2.Error {
var request rpc.HostValidateAuthenticationRequest
if err := json.Unmarshal(raw, &request); err != nil {
return jsonrpc2.ErrInvalidParams
}
return c.prepareAhpAuthentication(&request)
})
c.client.SetRequestHandler("host.authenticationCancelled", jsonrpc2.NotificationHandlerFor(c.cancelAhpAuthentication))
c.client.SetRequestHandler("host.sessionReleased", jsonrpc2.NotificationHandlerFor(c.releaseAhpSession))
c.client.SetRequestHandler("host.exited", jsonrpc2.NotificationHandlerFor(c.handleAhpExit))
}
func (c *Client) registerGitHubTokenProvider(provider GitHubTokenProvider) string {
if provider == nil {
return ""
}
registrationID := uuid.NewString()
c.gitHubTokenProvidersMux.Lock()
if c.gitHubTokenProviders == nil {
c.gitHubTokenProviders = make(map[string]GitHubTokenProvider)
}
c.gitHubTokenProviders[registrationID] = provider
c.gitHubTokenProvidersMux.Unlock()
return registrationID
}
func (c *Client) unregisterGitHubTokenProvider(registrationID string) {
if registrationID == "" {
return
}
c.gitHubTokenProvidersMux.Lock()
delete(c.gitHubTokenProviders, registrationID)
c.gitHubTokenProvidersMux.Unlock()
}
func (c *Client) clearGitHubTokenProviders() {
c.gitHubTokenProvidersMux.Lock()
c.gitHubTokenProviders = make(map[string]GitHubTokenProvider)
c.gitHubTokenProvidersMux.Unlock()
}
func (c *Client) handleConnectionClose() {
c.disconnectAhpHosts()
c.closeCopilotRequestAdapter()
c.closeInstallationConfirmationAdapter()
c.clearGitHubTokenProviders()
c.sessionsMux.Lock()
sessions := make([]*Session, 0, len(c.sessions))
for _, session := range c.sessions {
sessions = append(sessions, session)
}
c.sessionsMux.Unlock()
for _, session := range sessions {
session.clearSkillProvider()
session.cancelPendingExternalTools()
}
go func() {
c.startStopMux.Lock()
defer c.startStopMux.Unlock()
c.state = stateDisconnected
}()
}
func (c *Client) closeCopilotRequestAdapter() {
c.requestAdapterMux.Lock()
adapter := c.requestAdapter
c.requestAdapterMux.Unlock()
if adapter != nil {
adapter.close()
}
}
func (c *Client) closeInstallationConfirmationAdapter() {
c.installationConfirmationAdapterMux.Lock()
adapter := c.installationConfirmationAdapter
c.installationConfirmationAdapter = nil
c.installationConfirmationAdapterMux.Unlock()
if adapter != nil {
adapter.close()
}
}
func (c *Client) lockSessionOperation(sessionID string) func() {
c.sessionOperationsMux.Lock()
if c.sessionOperations == nil {
c.sessionOperations = make(map[string]*sessionOperation)
}
operation := c.sessionOperations[sessionID]
if operation == nil {
operation = &sessionOperation{}
c.sessionOperations[sessionID] = operation
}
operation.users++
c.sessionOperationsMux.Unlock()
operation.mutex.Lock()
return func() {
operation.mutex.Unlock()
c.sessionOperationsMux.Lock()
operation.users--
if operation.users == 0 {
delete(c.sessionOperations, sessionID)
}
c.sessionOperationsMux.Unlock()
}
}
type uiThreadAdapter struct {
host inProcessHost
runOnUiThread func(action func() error) error
}
func (a *uiThreadAdapter) Run(request *rpc.UIThreadRunRequest) (*rpc.UIThreadRunResult, error) {
if a.runOnUiThread == nil || a.host == nil {
return nil, fmt.Errorf("no in-process RunOnUiThread handler is configured")
}
if request.ActionID < 0 || uint64(request.ActionID) > 0xffffffff {
return nil, fmt.Errorf("UI action ID must be an unsigned 32-bit integer")
}
err := a.runOnUiThread(func() error {
return a.host.RunUiAction(uint32(request.ActionID))
})
return nil, err
}
type gitHubTokenAdapter struct {
client *Client
}
func (a *gitHubTokenAdapter) GetToken(request *rpc.GitHubTokenAcquireRequest) (rpc.GitHubTokenAcquireResult, error) {
if request == nil {
return nil, fmt.Errorf("missing GitHub token acquire request")
}
a.client.gitHubTokenProvidersMux.RLock()
provider := a.client.gitHubTokenProviders[request.RegistrationID]
a.client.gitHubTokenProvidersMux.RUnlock()
if provider == nil {
return nil, fmt.Errorf("unknown GitHub token provider registration ID %q", request.RegistrationID)
}
result, err := provider(GitHubTokenProviderArgs{
Host: request.Host,
SessionID: request.SessionID,
Reason: request.Reason,
})
if err != nil {
return nil, err
}
if result != nil && result.Cancelled {
return &rpc.GitHubTokenAcquireResultCancelled{}, nil
}
if result == nil || result.Token == nil {
return nil, fmt.Errorf("GitHub token provider returned neither a token nor cancellation")
}
return &rpc.GitHubTokenAcquireResultToken{
AccessToken: result.Token.AccessToken,
TokenType: result.Token.TokenType,
ExpiresIn: result.Token.ExpiresIn,
}, nil
}
type gitHubTelemetryAdapter struct {
callback func(notification *rpc.GitHubTelemetryNotification)
}
func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) error {
defer func() { recover() }()
a.callback(request)
return nil
}
func (c *Client) handleSessionEvent(req sessionEventRequest) {
if req.SessionID == "" {
return
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if ok {
session.dispatchEvent(req.Event)
}
}
func (c *Client) handleSkillProviderList(ctx context.Context, params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
var req rpc.SkillProviderListRequest
if err := json.Unmarshal(params, &req); err != nil || req.SessionID == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid skill provider list payload"}
}
provider, rpcErr := c.resolveSkillProvider(req.SessionID)
if rpcErr != nil {
return nil, rpcErr
}
skills, err := callSkillProvider(ctx, req.SessionID, "listSkills", func() ([]rpc.SkillProviderDescriptor, error) {
return provider.ListSkills(ctx)
})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: "Skill provider listSkills failed"}
}
if skills == nil {
skills = []rpc.SkillProviderDescriptor{}
}
raw, err := json.Marshal(rpc.SkillProviderListResult{Skills: skills})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)}
}
return raw, nil
}
func (c *Client) handleSkillProviderRead(ctx context.Context, params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
var req rpc.SkillProviderReadRequest
if err := json.Unmarshal(params, &req); err != nil || req.SessionID == "" || req.Name == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid skill provider read payload"}
}
provider, rpcErr := c.resolveSkillProvider(req.SessionID)
if rpcErr != nil {
return nil, rpcErr
}
markdown, err := callSkillProvider(ctx, req.SessionID, "readSkill", func() (*string, error) {
markdown, err := provider.ReadSkill(ctx, req.Name)
if errors.Is(err, ErrSkillNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &markdown, nil
})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: "Skill provider readSkill failed"}
}
raw, err := json.Marshal(rpc.SkillProviderReadResult{Markdown: markdown})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)}
}
return raw, nil
}
func (c *Client) resolveSkillProvider(sessionID string) (SkillProvider, *jsonrpc2.Error) {
c.sessionsMux.Lock()
session := c.sessions[sessionID]
c.sessionsMux.Unlock()
if session == nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No skill provider for session: %s", sessionID)}
}
provider := session.getSkillProvider()
if provider == nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No skill provider for session: %s", sessionID)}
}
return provider, nil
}
var errSkillProviderPanic = errors.New("skill provider panicked")
func callSkillProvider[T any](ctx context.Context, sessionID, operation string, call func() (T, error)) (result T, err error) {
defer func() {
if failure := recover(); failure != nil {
log.Printf("skill provider %s panicked: session_id=%s panic=%v\n%s", operation, sessionID, failure, debug.Stack())
var zero T
result, err = zero, errSkillProviderPanic
}
}()
result, err = call()
if err != nil && ctx.Err() == nil {
log.Printf("skill provider %s failed: session_id=%s error=%v", operation, sessionID, err)
}
return result, err
}
func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) {
if req.SessionID == "" || req.Question == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid user input request payload"}
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if !ok {
return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)}
}
response, err := session.handleUserInputRequest(UserInputRequest{
Question: req.Question,
Choices: req.Choices,
AllowFreeform: req.AllowFreeform,
})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()}
}
return &userInputResponse{Answer: response.Answer, WasFreeform: response.WasFreeform}, nil
}
func (c *Client) handleExitPlanModeRequest(req exitPlanModeRequest) (*ExitPlanModeResult, *jsonrpc2.Error) {
if req.SessionID == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid exit plan mode request payload"}
}
recommendedAction := req.RecommendedAction
if recommendedAction == "" {
recommendedAction = "autopilot"
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if !ok {
return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)}
}
response, err := session.handleExitPlanModeRequest(ExitPlanModeRequest{
Summary: req.Summary,
PlanContent: req.PlanContent,
Actions: req.Actions,
RecommendedAction: recommendedAction,
})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()}
}
return &response, nil
}
func (c *Client) handleAutoModeSwitchRequest(req autoModeSwitchRequest) (*autoModeSwitchResponse, *jsonrpc2.Error) {
if req.SessionID == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid auto mode switch request payload"}
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if !ok {
return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)}
}
response, err := session.handleAutoModeSwitchRequest(AutoModeSwitchRequest{
ErrorCode: req.ErrorCode,
RetryAfterSeconds: req.RetryAfterSeconds,
})
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()}
}
return &autoModeSwitchResponse{Response: response}, nil
}
func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jsonrpc2.Error) {
if req.SessionID == "" || req.Type == "" {
return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid hooks invoke payload"}
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if !ok {
return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)}
}
output, err := session.handleHooksInvoke(req.Type, req.Input)
if err != nil {
return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()}
}
result := make(map[string]any)
if output != nil {
result["output"] = output
}
return result, nil
}
type hooksAdapter struct {
client *Client
}
func (a *hooksAdapter) Invoke(request *rpc.HookInvokeRequest) (*rpc.HookInvokeResponse, error) {
rawInput, err := json.Marshal(request.Input)
if err != nil {
return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("invalid hooks invoke payload: %v", err)}
}
result, rpcErr := a.client.handleHooksInvoke(hooksInvokeRequest{
SessionID: request.SessionID,
Type: string(request.HookType),
Input: rawInput,
})
if rpcErr != nil {
return nil, rpcErr
}
response := &rpc.HookInvokeResponse{}
if result != nil {
response.Output = result["output"]
}
return response, nil
}
func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) (systemMessageTransformResponse, *jsonrpc2.Error) {
if req.SessionID == "" {
return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: "invalid system message transform payload"}
}
c.sessionsMux.Lock()
session, ok := c.sessions[req.SessionID]
c.sessionsMux.Unlock()
if !ok {
return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)}
}
resp, err := session.handleSystemMessageTransform(req.Sections)
if err != nil {
return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32603, Message: err.Error()}
}
return resp, nil
}