// Package copilot provides a Go SDK for interacting with the GitHub Copilot CLI. // // The copilot package enables Go applications to communicate with the Copilot CLI // server, create and manage conversation sessions, and integrate custom tools. // // Basic usage: // // client := copilot.NewClient(nil) // if err := client.Start(); err != nil { // log.Fatal(err) // } // defer client.Stop() // // session, err := client.CreateSession(&copilot.SessionConfig{ // OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // }) // if err != nil { // log.Fatal(err) // } // // session.On(func(event copilot.SessionEvent) { // if d, ok := event.Data.(*copilot.AssistantMessageData); ok { // fmt.Println(d.Content) // } // }) // // session.Send(copilot.MessageOptions{Prompt: "Hello!"}) package copilot import ( "bufio" "context" "encoding/json" "errors" "fmt" "net" "os" "os/exec" "regexp" "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" ) 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 } // Client manages the connection to the Copilot CLI server and provides session management. // // The Client can either spawn a CLI server process or connect to an existing server. // It handles JSON-RPC communication, session lifecycle, tool execution, and permission requests. // // Example: // // // Create a client with default options (spawns CLI server using stdio) // client := copilot.NewClient(nil) // // // Or connect to an existing server // client := copilot.NewClient(&copilot.ClientOptions{ // CLIUrl: "localhost:3000", // }) // // if err := client.Start(); err != nil { // log.Fatal(err) // } // defer client.Stop() type Client struct { options ClientOptions process *exec.Cmd client *jsonrpc2.Client actualPort int actualHost string state connectionState sessions map[string]*Session sessionsMux sync.Mutex isExternalServer bool conn net.Conn // stores net.Conn for external TCP connections useStdio bool // resolved value from options // resolved process options for the spawned runtime (zero values for UriConnection) 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 // protects process and state during start/[force]stop processDone chan struct{} processErrorPtr *error osProcess atomic.Pointer[os.Process] negotiatedProtocolVersion int // effectiveConnectionToken is the token sent in `connect`; auto-generated when // the SDK spawns its own CLI in TCP mode. effectiveConnectionToken string onListModels func(ctx context.Context) ([]ModelInfo, error) // RPC provides typed server-scoped RPC methods. // This field is nil until the client is connected via Start(). RPC *rpc.ServerRpc // internalRPC provides SDK-internal RPC methods (handshake helpers etc.). // Lowercase = not exported; external callers cannot reach it. internalRPC *rpc.InternalServerRpc } // NewClient creates a new Copilot runtime client with the given options. // // If options is nil, default options are used (spawns the bundled runtime over // stdio). The client is not connected after creation; call [Client.Start] to // connect, or simply call [Client.CreateSession]/[Client.ResumeSession], which // auto-start the runtime on first use. // // Example: // // // Default options: bundled runtime over stdio // client := copilot.NewClient(nil) // // // Custom CLI path over stdio // client := copilot.NewClient(&copilot.ClientOptions{ // Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, // LogLevel: "debug", // }) // // // Connect to an already-running runtime // client := copilot.NewClient(&copilot.ClientOptions{ // Connection: copilot.UriConnection{URL: "localhost:8080"}, // }) func NewClient(options *ClientOptions) *Client { opts := ClientOptions{} client := &Client{ options: opts, state: stateDisconnected, sessions: make(map[string]*Session), actualHost: "localhost", isExternalServer: false, useStdio: true, } if options != nil { opts = *options } // Resolve the connection. nil defaults to an empty StdioConnection. connection := opts.Connection if connection == nil { connection = StdioConnection{} } 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 default: panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection)) } // Validate auth options when connecting to an external runtime. if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) { panic("GitHubToken and UseLoggedInUser cannot be used with UriConnection (external runtime manages its own auth)") } // Default Env to current environment if not set if opts.Env == nil { opts.Env = os.Environ() } // Check effective environment for CLI path (only if not explicitly set via options) if client.cliPath == "" { if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" { client.cliPath = cliPath } } // Resolve the effective connection token: explicit value if set; else if the SDK // spawns its own runtime in TCP mode, generate a UUID; otherwise empty. if client.tcpConnectionToken != "" { client.effectiveConnectionToken = client.tcpConnectionToken } else if !client.useStdio && !client.isExternalServer { 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 return client } // getEnvValue looks up a key in an environment slice ([]string of "KEY=VALUE"). // Returns the value if found, or empty string otherwise. 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 "" } // setEnvValue returns a copy of env with all existing entries for key removed and // a single trailing KEY=VALUE entry added so SDK-managed values win deterministically. 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) } // parseCliUrl parses a CLI URL into host and port components. // // Supports formats: "host:port", "http://host:port", "https://host:port", or just "port". // Panics if the URL format is invalid or the port is out of range. func parseCliUrl(url string) (string, int) { // Remove protocol if present cleanUrl, _ := strings.CutPrefix(url, "https://") cleanUrl, _ = strings.CutPrefix(cleanUrl, "http://") // Parse host:port or port format var host string var portStr string if before, after, found := strings.Cut(cleanUrl, ":"); found { host = before portStr = after } else { // Only port provided portStr = before } if host == "" { host = "localhost" } // Validate port port, err := strconv.Atoi(portStr) if err != nil || port <= 0 || port > 65535 { panic(fmt.Sprintf("Invalid port in CLIUrl: %s", url)) } return host, port } // Start starts the CLI server (if not using an external server) and establishes // a connection. // // If connecting to an external server (via CLIUrl), only establishes the connection. // Otherwise, spawns the CLI server process and then connects. // // This method is called automatically when creating a session if AutoStart is true (default). // // Returns an error if the server fails to start or the connection fails. // // Example: // // client := copilot.NewClient(&copilot.ClientOptions{AutoStart: boolPtr(false)}) // if err := client.Start(context.Background()); err != nil { // log.Fatal("Failed to start:", err) // } // // Now ready to create sessions func (c *Client) Start(ctx context.Context) error { c.startStopMux.Lock() defer c.startStopMux.Unlock() if c.state == stateConnected { return nil } c.state = stateConnecting // Only start CLI server process if not connecting to external server if !c.isExternalServer { if err := c.startCLIServer(ctx); err != nil { c.process = nil c.state = stateError return err } } // Connect to the server if err := c.connectToServer(ctx); err != nil { killErr := c.killProcess() c.state = stateError return errors.Join(err, killErr) } // Verify protocol version compatibility if err := c.verifyProtocolVersion(ctx); err != nil { killErr := c.killProcess() c.state = stateError return errors.Join(err, killErr) } // If a session filesystem provider was configured, register it. 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 req.Capabilities = &rpc.SessionFsSetProviderCapabilities{ Sqlite: &sqlite, } } _, err := c.RPC.SessionFs.SetProvider(ctx, req) if err != nil { killErr := c.killProcess() c.state = stateError return errors.Join(err, killErr) } } c.state = stateConnected return nil } // Stop stops the CLI server and closes all active sessions. // // This method performs graceful cleanup: // 1. Closes all active sessions (releases in-memory resources) // 2. Closes the JSON-RPC connection // 3. Terminates the CLI server process (if spawned by this client) // // Note: session data on disk is preserved, so sessions can be resumed later. // To permanently remove session data before stopping, call [Client.DeleteSession] // for each session first. // // Returns an error that aggregates all errors encountered during cleanup. // // Example: // // if err := client.Stop(); err != nil { // log.Printf("Cleanup error: %v", err) // } func (c *Client) Stop() error { var errs []error // Disconnect all active sessions 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.startStopMux.Lock() defer c.startStopMux.Unlock() // Kill CLI process FIRST (this closes stdout and unblocks readLoop) - only if we spawned it if c.process != nil && !c.isExternalServer { if err := c.killProcess(); err != nil { errs = append(errs, err) } } c.process = nil // Close external TCP connection if exists 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 } // Then close JSON-RPC client (readLoop can now exit) if c.client != nil { c.client.Stop() c.client = nil } // Clear models cache 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...) } // ForceStop forcefully stops the CLI server without graceful cleanup. // // Use this when [Client.Stop] fails or takes too long. This method: // - Clears all sessions immediately without destroying them // - Force closes the connection // - Kills the CLI process (if spawned by this client) // // Example: // // // If normal stop hangs, force stop // done := make(chan struct{}) // go func() { // client.Stop() // close(done) // }() // // select { // case <-done: // // Stopped successfully // case <-time.After(5 * time.Second): // client.ForceStop() // } func (c *Client) ForceStop() { // Kill the process without waiting for startStopMux, which Start may hold. // This unblocks any I/O Start is doing (connect, version check). if p := c.osProcess.Swap(nil); p != nil { p.Kill() } // Clear sessions immediately without trying to destroy them c.sessionsMux.Lock() c.sessions = make(map[string]*Session) c.sessionsMux.Unlock() c.startStopMux.Lock() defer c.startStopMux.Unlock() // Kill CLI process (only if we spawned it) // This is a fallback in case the process wasn't killed above (e.g. if Start hadn't set // osProcess yet), or if the process was restarted and osProcess now points to a new process. if c.process != nil && !c.isExternalServer { _ = c.killProcess() // Ignore errors since we're force stopping } c.process = nil // Close external TCP connection if exists if c.isExternalServer && c.conn != nil { _ = c.conn.Close() // Ignore errors c.conn = nil } // Close JSON-RPC client if c.client != nil { c.client.Stop() c.client = nil } // Clear models cache 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) } // CreateSession creates a new conversation session with the Copilot CLI. // // Sessions maintain conversation state, handle events, and manage tool execution. // If the client is not connected, this will automatically start the runtime. // // The config parameter is optional. If no OnPermissionRequest handler is provided, // permission requests are surfaced as events for the caller to resolve manually. // // Returns the created session or an error if session creation fails. // // Example: // // // Basic session // session, err := client.CreateSession(context.Background(), nil) // // // Session with model and tools // session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ // OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // Tools: []copilot.Tool{ // { // Name: "get_weather", // Description: "Get weather for a location", // Handler: weatherHandler, // }, // }, // }) // // extractTransformCallbacks separates transform callbacks from a SystemMessageConfig, // returning a wire-safe config and a map of callbacks (nil if none). 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 (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil { config = &SessionConfig{} } if err := c.ensureConnected(ctx); err != nil { return nil, err } req := createSessionRequest{} req.Model = config.Model req.ClientName = config.ClientName req.ReasoningEffort = config.ReasoningEffort req.ConfigDir = config.ConfigDir if config.EnableConfigDiscovery { req.EnableConfigDiscovery = Bool(true) } req.Tools = config.Tools wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) req.SystemMessage = wireSystemMessage req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.InstructionDirectories = config.InstructionDirectories req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Cloud = config.Cloud 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.Streaming != nil { req.Streaming = config.Streaming } if config.IncludeSubAgentStreamingEvents != nil { req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents } else { req.IncludeSubAgentStreamingEvents = 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.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil) { req.Hooks = Bool(true) } if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) } traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent req.Tracestate = tracestate sessionID := config.SessionID if sessionID == "" { sessionID = uuid.New().String() } req.SessionID = sessionID // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) 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) } c.sessionsMux.Lock() c.sessions[sessionID] = session c.sessionsMux.Unlock() if c.options.SessionFs != nil { if config.CreateSessionFsProvider == nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() 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 { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("SessionFs capabilities declare SQLite support but the provider does not implement SessionFsSqliteProvider") } } session.clientSessionApis.SessionFs = newSessionFsAdapter(provider) } result, err := c.client.Request("session.create", req) if err != nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to create session: %w", err) } var response createSessionResponse if err := json.Unmarshal(result, &response); err != nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to unmarshal response: %w", err) } session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) return session, nil } // ResumeSession resumes an existing conversation session by its ID. // // This is a convenience method that calls [Client.ResumeSessionWithOptions]. // Example: // // session, err := client.ResumeSession(context.Background(), "session-123", &copilot.ResumeSessionConfig{ // OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // }) func (c *Client) ResumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { return c.ResumeSessionWithOptions(ctx, sessionID, config) } // ResumeSessionWithOptions resumes an existing conversation session with additional configuration. // // This allows you to continue a previous conversation, maintaining all conversation history. // The session must have been previously created and not deleted. // // Example: // // session, err := client.ResumeSessionWithOptions(context.Background(), "session-123", &copilot.ResumeSessionConfig{ // OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Tools: []copilot.Tool{myNewTool}, // }) func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { if config == nil { config = &ResumeSessionConfig{} } if err := c.ensureConnected(ctx); err != nil { return nil, err } var req resumeSessionRequest req.SessionID = sessionID req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools if config.Streaming != nil { req.Streaming = config.Streaming } if config.IncludeSubAgentStreamingEvents != nil { req.IncludeSubAgentStreamingEvents = config.IncludeSubAgentStreamingEvents } else { req.IncludeSubAgentStreamingEvents = 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.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil) { req.Hooks = Bool(true) } req.WorkingDirectory = config.WorkingDirectory req.ConfigDir = config.ConfigDir if config.EnableConfigDiscovery { req.EnableConfigDiscovery = Bool(true) } if config.SuppressResumeEvent { req.DisableResume = Bool(true) } if config.ContinuePendingWork { req.ContinuePendingWork = Bool(true) } req.MCPServers = config.MCPServers req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.InstructionDirectories = config.InstructionDirectories req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession if config.OnPermissionRequest != 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) } traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent req.Tracestate = tracestate // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) 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) } c.sessionsMux.Lock() c.sessions[sessionID] = session c.sessionsMux.Unlock() if c.options.SessionFs != nil { if config.CreateSessionFsProvider == nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() 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 { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("SessionFs capabilities declare SQLite support but the provider does not implement SessionFsSqliteProvider") } } session.clientSessionApis.SessionFs = newSessionFsAdapter(provider) } result, err := c.client.Request("session.resume", req) if err != nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to resume session: %w", err) } var response resumeSessionResponse if err := json.Unmarshal(result, &response); err != nil { c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to unmarshal response: %w", err) } session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) return session, nil } // ListSessions returns metadata about all sessions known to the server. // // Returns a list of SessionMetadata for all available sessions, including their IDs, // timestamps, optional summaries, and context information. // // An optional filter can be provided to filter sessions by working directory, git root, repository, or branch. // // Example: // // sessions, err := client.ListSessions(context.Background(), nil) // if err != nil { // log.Fatal(err) // } // for _, session := range sessions { // fmt.Printf("Session: %s\n", session.SessionID) // } // // Example with filter: // // sessions, err := client.ListSessions(context.Background(), &SessionListFilter{Repository: "owner/repo"}) 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("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 } // GetSessionMetadata returns metadata for a specific session by ID. // // This provides an efficient O(1) lookup of a single session's metadata // instead of listing all sessions. Returns nil if the session is not found. // // Example: // // metadata, err := client.GetSessionMetadata(context.Background(), "session-123") // if err != nil { // log.Fatal(err) // } // if metadata != nil { // fmt.Printf("Session started at: %s\n", metadata.StartTime) // } 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("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 } // DeleteSession permanently deletes a session and all its data from disk, // including conversation history, planning state, and artifacts. // // Unlike [Session.Disconnect], which only releases in-memory resources and // preserves session data for later resumption, DeleteSession is irreversible. // The session cannot be resumed after deletion. If the session is in the local // sessions map, it will be removed. // // Example: // // if err := client.DeleteSession(context.Background(), "session-123"); err != nil { // log.Fatal(err) // } func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { if err := c.ensureConnected(ctx); err != nil { return err } result, err := c.client.Request("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) } // Remove from local sessions map if present c.sessionsMux.Lock() delete(c.sessions, sessionID) c.sessionsMux.Unlock() return nil } // GetLastSessionID returns the ID of the most recently updated session. // // This is useful for resuming the last conversation when the session ID // was not stored. Returns nil if no sessions exist. // // Example: // // lastID, err := client.GetLastSessionID(context.Background()) // if err != nil { // log.Fatal(err) // } // if lastID != nil { // session, err := client.ResumeSession(context.Background(), *lastID, &copilot.ResumeSessionConfig{ // OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // }) // } func (c *Client) GetLastSessionID(ctx context.Context) (*string, error) { if err := c.ensureConnected(ctx); err != nil { return nil, err } result, err := c.client.Request("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 } // GetForegroundSessionID returns the ID of the session currently displayed in the TUI. // // This is only available when connecting to a server running in TUI+server mode // (--ui-server). Returns nil if no foreground session is set. // // Example: // // sessionID, err := client.GetForegroundSessionID() // if err != nil { // log.Fatal(err) // } // if sessionID != nil { // fmt.Printf("TUI is displaying session: %s\n", *sessionID) // } func (c *Client) GetForegroundSessionID(ctx context.Context) (*string, error) { if err := c.ensureConnected(ctx); err != nil { return nil, err } result, err := c.client.Request("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 } // SetForegroundSessionID requests the TUI to switch to displaying the specified session. // // This is only available when connecting to a server running in TUI+server mode // (--ui-server). // // Example: // // if err := client.SetForegroundSessionID("session-123"); err != nil { // log.Fatal(err) // } func (c *Client) SetForegroundSessionID(ctx context.Context, sessionID string) error { if err := c.ensureConnected(ctx); err != nil { return err } result, err := c.client.Request("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 } // On subscribes to all session lifecycle events. // // Lifecycle events are emitted when sessions are created, deleted, updated, // or change foreground/background state (in TUI+server mode). // // Returns a function that, when called, unsubscribes the handler. // // Example: // // unsubscribe := client.On(func(event copilot.SessionLifecycleEvent) { // fmt.Printf("Session %s: %s\n", event.SessionID, event.Type) // }) // defer unsubscribe() 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) } } // OnEventType subscribes to a specific session lifecycle event type. // // Returns a function that, when called, unsubscribes the handler. // // Example: // // unsubscribe := client.OnEventType(copilot.SessionLifecycleForeground, func(event copilot.SessionLifecycleEvent) { // fmt.Printf("Session %s is now in foreground\n", event.SessionID) // }) // defer unsubscribe() 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) } } } // handleLifecycleEvent dispatches a lifecycle event to all registered handlers func (c *Client) handleLifecycleEvent(event SessionLifecycleEvent) { c.lifecycleHandlersMux.Lock() // Copy handlers to avoid holding lock during callbacks 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() // Dispatch to typed handlers for _, handler := range typedHandlers { func() { defer func() { recover() }() // Ignore handler panics handler(event) }() } // Dispatch to wildcard handlers for _, handler := range wildcardHandlers { func() { defer func() { recover() }() // Ignore handler panics handler(event) }() } } // RuntimePort returns the TCP port the runtime is listening on. // Returns 0 if the client is not connected or using stdio transport. func (c *Client) RuntimePort() int { return c.actualPort } // Ping sends a ping request to the server to verify connectivity. // // The message parameter is optional and will be echoed back in the response. // Returns a PingResponse containing the message and server timestamp, or an error. // // Example: // // resp, err := client.Ping(context.Background(), "health check") // if err != nil { // log.Printf("Server unreachable: %v", err) // } else { // log.Printf("Server responded at %s", resp.Timestamp) // } 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("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 } // GetStatus returns CLI status including version and protocol information 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("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 } // GetAuthStatus returns current authentication status 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("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 } // ListModels returns available models with their metadata. // // Results are cached after the first successful call to avoid rate limiting. // The cache is cleared when the client disconnects. func (c *Client) ListModels(ctx context.Context) ([]ModelInfo, error) { // Use mutex for locking to prevent race condition with concurrent calls c.modelsCacheMux.Lock() defer c.modelsCacheMux.Unlock() // Check cache (already inside lock) if c.modelsCache != nil { result := make([]ModelInfo, len(c.modelsCache)) copy(result, c.modelsCache) return result, nil } var models []ModelInfo if c.onListModels != nil { // Use custom handler instead of CLI RPC 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") } // Cache miss - fetch from backend while holding lock result, err := c.client.Request("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 } // Update cache before releasing lock (copy to prevent external mutation) cache := make([]ModelInfo, len(models)) copy(cache, models) c.modelsCache = cache // Return a copy to prevent cache mutation result := make([]ModelInfo, len(models)) copy(result, models) return result, nil } // minProtocolVersion is the minimum protocol version this SDK can communicate with. const minProtocolVersion = 3 // verifyProtocolVersion sends the `connect` handshake (carrying the optional token) and // verifies the server's protocol version. Falls back to `ping` against legacy servers // that don't implement `connect`. 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 } connectResult, err := c.internalRPC.Connect(ctx, &rpc.ConnectRequest{Token: tokenPtr}) if err != nil { var rpcErr *jsonrpc2.Error if errors.As(err, &rpcErr) && (rpcErr.Code == jsonrpc2.ErrMethodNotFound.Code || rpcErr.Message == "Unhandled method connect") { // Legacy server without `connect`; fall back to `ping`. A token, if any, // is silently dropped — the legacy server can't enforce one. pingResult, perr := c.Ping(ctx, "") if perr != nil { return perr } serverVersion = pingResult.ProtocolVersion } else { return err } } else { 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 } // stderrBufferSize is the maximum number of bytes kept from the CLI process's // stderr. Only the tail is retained so that memory stays bounded even when the // process produces a large amount of diagnostic output. const stderrBufferSize = 64 * 1024 // startCLIServer starts the CLI server process. // // This spawns the CLI server as a subprocess using the configured transport // mode (stdio or TCP). func (c *Client) startCLIServer(ctx context.Context) error { cliPath := c.cliPath if cliPath == "" { // If no CLI path is provided, attempt to use the embedded CLI if available cliPath = embeddedcli.Path() } if cliPath == "" { // Default to "copilot" in PATH if no embedded CLI is available and no custom path is set cliPath = "copilot" } // Start with user-provided CLIArgs, then add SDK-managed args args := append([]string{}, c.cliArgs...) args = append(args, "--headless", "--no-auto-update") // Only pass --log-level when explicitly configured; otherwise let the // runtime use its own default. if c.options.LogLevel != "" { args = append(args, "--log-level", c.options.LogLevel) } // Choose transport mode if c.useStdio { args = append(args, "--stdio") } else if c.port > 0 { args = append(args, "--port", strconv.Itoa(c.port)) } // Add auth-related flags if c.options.GitHubToken != "" { args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN") } // Default useLoggedInUser to false when GitHubToken is provided 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 c.options.EnableRemoteSessions { 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 if strings.HasSuffix(cliPath, ".js") { command = "node" args = append([]string{cliPath}, args...) } c.process = exec.Command(command, args...) // Configure platform-specific process attributes (e.g., hide window on Windows) configureProcAttr(c.process) // Set working directory if specified 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.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.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 { // For stdio mode, we need stdin/stdout pipes 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.monitorProcess() // Create JSON-RPC client immediately c.client = jsonrpc2.NewClient(stdin, stdout) c.client.SetProcessDone(c.processDone, c.processErrorPtr) c.client.SetOnClose(func() { // Run in a goroutine to avoid deadlocking with Stop/ForceStop, // which hold startStopMux while waiting for readLoop to finish. go func() { c.startStopMux.Lock() defer c.startStopMux.Unlock() c.state = stateDisconnected }() }) c.RPC = rpc.NewServerRpc(c.client) c.internalRPC = rpc.NewInternalServerRpc(c.client) c.setupNotificationHandler() c.client.Start() return nil } else { // For TCP mode, capture stdout to get port number 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) killProcess() error { if p := c.osProcess.Swap(nil); p != nil { if err := p.Kill(); err != nil { return fmt.Errorf("failed to kill CLI process: %w", err) } } c.process = nil return nil } // monitorProcess signals when the CLI process exits and captures any exit error. // processError is intentionally a local: each process lifecycle gets its own // error value, so goroutines from previous processes can't overwrite the // current one. Closing the channel synchronizes with readers, guaranteeing // they see the final processError value. 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) }() } // connectToServer establishes a connection to the server. func (c *Client) connectToServer(ctx context.Context) error { if c.useStdio { // Already connected via stdio in startCLIServer return nil } // Connect via TCP return c.connectViaTcp(ctx) } // connectViaTcp connects to the CLI server via TCP socket. func (c *Client) connectViaTcp(ctx context.Context) error { if c.actualPort == 0 { return fmt.Errorf("server port not available") } // Merge a 10-second timeout with the caller's context so whichever // deadline comes first wins. 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 // Create JSON-RPC client with the connection c.client = jsonrpc2.NewClient(conn, conn) if c.processDone != nil { c.client.SetProcessDone(c.processDone, c.processErrorPtr) } c.client.SetOnClose(func() { go func() { c.startStopMux.Lock() defer c.startStopMux.Unlock() c.state = stateDisconnected }() }) c.RPC = rpc.NewServerRpc(c.client) c.internalRPC = rpc.NewInternalServerRpc(c.client) c.setupNotificationHandler() c.client.Start() return nil } // setupNotificationHandler configures handlers for session events and RPC requests. 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("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) 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 }) } func (c *Client) handleSessionEvent(req sessionEventRequest) { if req.SessionID == "" { return } // Dispatch to session c.sessionsMux.Lock() session, ok := c.sessions[req.SessionID] c.sessionsMux.Unlock() if ok { session.dispatchEvent(req.Event) } } // handleUserInputRequest handles a user input request from the CLI server. 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 } // handleExitPlanModeRequest handles an exitPlanMode.request callback from the CLI server. 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 } // handleAutoModeSwitchRequest handles an autoModeSwitch.request callback from the CLI server. 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 } // handleHooksInvoke handles a hooks invocation from the CLI server. 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 } // handleSystemMessageTransform handles a system message transform request from the CLI server. 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 }