/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ /** * Copilot CLI SDK Client - Main entry point for the Copilot SDK. * * This module provides the {@link CopilotClient} class, which manages the connection * to the Copilot CLI server and provides session management capabilities. * * @module client */ import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { isIPv6, Socket } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { type CancellationToken, createMessageConnection as createRpcMessageConnection, type DataCallback, ErrorCodes, type Message, MessageConnection, ResponseError, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; import { createServerRpc, createInternalServerRpc, registerClientGlobalApiHandlers, registerClientSessionApiHandlers, } from "./generated/rpc.js"; import type { ConnectClientInfo, GitHubTelemetryNotification, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, InstallationConfirmationRequest, OpenCanvasInstance, SessionUpdateOptionsParams, TaskKind, } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { AhpHost, type AhpHostExit, type AhpHostOptions } from "./host.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { ensureRuntimeBundle } from "./runtimeArtifacts.js"; import { COPILOT_CLI_VERSION } from "./cliVersion.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { createCopilotRequestAdapter } from "./copilotRequestHandler.js"; import { createInstallationConfirmationAdapter, type InstallationConfirmationHandler, } from "./installationConfirmation.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import { getTraceContext } from "./telemetry.js"; import { toJsonSchema } from "./schema.js"; import { ToolSet } from "./toolSet.js"; import type { AutoModeSwitchRequest, AutoModeSwitchResponse, CopilotClientInfo, CopilotClientMode, CopilotClientOptions, CustomAgentConfig, ExitPlanModeRequest, ExitPlanModeResult, ExtensionLaunchProvider, ExtensionJoinOptions, ForegroundSessionInfo, GetAuthStatusResponse, BearerTokenProvider, GitHubTokenProvider, GetStatusResponse, InternalRuntimeConnection, RuntimeConnection, LargeToolOutputConfig, MCPServerConfig, ModelInfo, NamedProviderConfig, ProviderConfig, ResumeSessionConfig, SectionTransformFn, SessionConfig, SessionConfigBase, SystemMessageConfig, SessionCapabilities, SessionEvent, SessionFsConfig, SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, SessionListFilter, SessionMetadata, SystemMessageCustomizeConfig, TelemetryConfig, TraceContextProvider, TypedSessionLifecycleHandler, } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; import type { WorkflowHandle } from "./workflow.js"; interface ExtensionOrchestrationContributions { workflows?: WorkflowHandle[]; } /** * Minimum protocol version this SDK can communicate with. * Servers reporting a version below this are rejected. */ const MIN_PROTOCOL_VERSION = 3; const RUNTIME_SHUTDOWN_TIMEOUT_MS = 10_000; type DrainingMessageConnection = MessageConnection & { drain: () => Promise }; function createMessageConnection( reader: StreamMessageReader, writer: StreamMessageWriter ): DrainingMessageConnection { let dispatch: DataCallback | undefined; let finishDrain: (() => void) | undefined; let drained: Promise | undefined; let disposed = false; const barrier = { jsonrpc: "2.0", method: "$/sdkDrain" }; const connection = createRpcMessageConnection( { onError: reader.onError, onClose: reader.onClose, onPartialMessage: reader.onPartialMessage, listen: (callback) => { dispatch = callback; return reader.listen(callback); }, dispose: () => reader.dispose(), }, writer, undefined, { messageStrategy: { handleMessage: (message, next) => { if (message === barrier) { finishDrain?.(); } else { next(message); } }, }, } ); connection.onDispose(() => { disposed = true; finishDrain?.(); }); return Object.assign(connection, { drain: () => { if (!drained) { drained = new Promise((resolve) => { finishDrain = resolve; if (disposed || !dispatch) { resolve(); } else { // A local queue barrier preserves every message parsed before EOF. // It is never sent on the wire or exposed to notification handlers. dispatch(barrier); } }); } return drained; }, }); } async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timeout: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(message)), timeoutMs); }), ]); } finally { if (timeout !== undefined) { clearTimeout(timeout); } } } async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode != null || child.signalCode != null) { return true; } return new Promise((resolve) => { let timeout: ReturnType; let settled = false; const onExit = () => { if (settled) { return; } settled = true; clearTimeout(timeout); resolve(true); }; timeout = setTimeout(() => { if (settled) { return; } settled = true; child.off("exit", onExit); resolve(false); }, timeoutMs); child.once("exit", onExit); if (child.exitCode != null || child.signalCode != null) { onExit(); } }); } /** Implicit provider name for the singular, whole-session {@link ProviderConfig}. */ const DEFAULT_PROVIDER_NAME = "default"; /** Wire-safe singular provider config carrying the `hasBearerTokenProvider` flag. */ type WireProviderConfig = Omit & { hasBearerTokenProvider?: boolean; }; /** Wire-safe named provider config carrying the `hasBearerTokenProvider` flag. */ type WireNamedProviderConfig = Omit & { hasBearerTokenProvider?: boolean; }; /** * Strips the non-serializable {@link BearerTokenProvider} callbacks from the singular * and named provider configs before they cross the RPC boundary, replacing each * with a `hasBearerTokenProvider: true` wire flag. The callback closes over its * own token scope/audience, so nothing scope-related crosses the wire — the * runtime only forwards the provider name back when it needs a token. * Returns wire-safe provider configs alongside a map of provider name → callback * for session-side registration. */ function extractBearerTokenProviders( provider: ProviderConfig | undefined, providers: NamedProviderConfig[] | undefined ): { wireProvider: WireProviderConfig | undefined; wireProviders: WireNamedProviderConfig[] | undefined; callbacks: Map; } { const callbacks = new Map(); let wireProvider: WireProviderConfig | undefined = provider; if (provider?.bearerTokenProvider) { const { bearerTokenProvider, ...rest } = provider; callbacks.set(DEFAULT_PROVIDER_NAME, bearerTokenProvider); wireProvider = { ...rest, hasBearerTokenProvider: true, }; } let wireProviders: WireNamedProviderConfig[] | undefined = providers; if (providers?.some((p) => p.bearerTokenProvider)) { wireProviders = providers.map((p) => { if (!p.bearerTokenProvider) return p; const { bearerTokenProvider, ...rest } = p; callbacks.set(p.name, bearerTokenProvider); return { ...rest, hasBearerTokenProvider: true, }; }); } return { wireProvider, wireProviders, callbacks }; } /** * Convert MCP server configs from public API format (workingDirectory) to * wire format (cwd) expected by the runtime. */ function toWireMcpServers( mcpServers: Record | undefined ): Record | undefined { if (!mcpServers) return undefined; return Object.fromEntries( Object.entries(mcpServers).map(([name, server]) => { if ("workingDirectory" in server) { const { workingDirectory, ...rest } = server; return [name, { ...rest, cwd: workingDirectory }]; } return [name, server]; }) ); } /** * Convert custom agent configs, transforming nested mcpServers from * public API format (workingDirectory) to wire format (cwd). */ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] | undefined { if (!agents) return undefined; return agents.map((agent) => { if (!agent.mcpServers) return agent; const { mcpServers, ...rest } = agent; return { ...rest, mcpServers: toWireMcpServers(mcpServers) }; }); } /** * Map the public {@link CopilotClientInfo} onto the generated connect wire * shape, dropping empty fields. Returns `undefined` when no field carries a * non-empty value so the caller omits `clientInfo` from the handshake and keeps * the runtime's default attribution. */ function clientInfoToWire(info: CopilotClientInfo | undefined): ConnectClientInfo | undefined { if (info == null) return undefined; const wire: ConnectClientInfo = {}; if (info.applicationName) wire.editorName = info.applicationName; if (info.applicationVersion) wire.editorVersion = info.applicationVersion; if (info.integrationName) wire.extensionName = info.integrationName; if (info.integrationVersion) wire.extensionVersion = info.integrationVersion; return Object.keys(wire).length > 0 ? wire : undefined; } /** * Convert a {@link LargeToolOutputConfig} from the public API shape * (`outputDirectory`) to the wire shape (`outputDir`). */ function toWireLargeOutput( config: LargeToolOutputConfig | undefined ): Record | undefined { if (!config) return undefined; const { outputDirectory, ...rest } = config; const wire: Record = { ...rest }; if (outputDirectory !== undefined) { wire.outputDir = outputDirectory; } return wire; } function toolFilterListToArray(value: string[] | ToolSet | undefined): string[] | undefined { if (value === undefined) { return undefined; } return value instanceof ToolSet ? value.toArray() : value; } /** * Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so * users get an actionable error rather than a silently-empty filter. * * The runtime treats a bare `"*"` as a literal name match for a tool whose * name is the single character `*`, which the runtime's charset guard would * reject at registration — so the filter effectively matches nothing. We * surface that here as an error pointing the developer at the source-qualified * forms produced by {@link ToolSet}. */ function validateToolFilterList(field: string, list: string[] | undefined): void { if (!list) { return; } for (const entry of list) { if (entry === "*") { throw new Error( `Invalid ${field} entry '*': there is no bare wildcard. ` + "Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " + "or `.addCustom('*')` to target a specific source." ); } } } /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, * and the original callbacks are returned in a separate map. */ function extractTransformCallbacks(systemMessage: SessionConfig["systemMessage"]): { wirePayload: SessionConfig["systemMessage"]; transformCallbacks: Map | undefined; } { if (!systemMessage || systemMessage.mode !== "customize" || !systemMessage.sections) { return { wirePayload: systemMessage, transformCallbacks: undefined }; } const transformCallbacks = new Map(); const wireSections: Record = {}; for (const [sectionId, override] of Object.entries(systemMessage.sections)) { if (!override) continue; if (typeof override.action === "function") { transformCallbacks.set(sectionId, override.action); wireSections[sectionId] = { action: "transform" }; } else { wireSections[sectionId] = { action: override.action, content: override.content }; } } if (transformCallbacks.size === 0) { return { wirePayload: systemMessage, transformCallbacks: undefined }; } const wirePayload: SystemMessageCustomizeConfig = { ...systemMessage, sections: wireSections as SystemMessageCustomizeConfig["sections"], }; return { wirePayload, transformCallbacks }; } function getNodeExecPath(): string { if (process.versions.bun) { return "node"; } return process.execPath; } function getBundledRuntimePath(): Promise { return ensureRuntimeBundle(COPILOT_CLI_VERSION); } /** * Main client for interacting with the Copilot CLI. * * The CopilotClient manages the connection to the Copilot CLI server and provides * methods to create and manage conversation sessions. It can either spawn a CLI * server process or connect to an existing server. * * @example * ```typescript * import { CopilotClient } from "@github/copilot-sdk"; * * // Create a client with default options (spawns CLI server) * const client = new CopilotClient(); * * // Or connect to an existing server * const client = new CopilotClient({ connection: RuntimeConnection.forUri("localhost:3000") }); * * // Create a session * const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" }); * * // Send messages and handle responses * session.on((event) => { * if (event.type === "assistant.message") { * console.log(event.data.content); * } * }); * await session.send({ prompt: "Hello!" }); * * // Clean up * await session.disconnect(); * await client.stop(); * ``` */ /** * A {@link StreamMessageWriter} that suppresses write failures while the client * is tearing down its transport. * * During `stop()`/`forceStop()` or transport EOF the runtime's end of the pipe * can close while vscode-jsonrpc still has an in-flight write — most commonly the * auto-generated response to a server→client request (tool/hook/userInput/LLM * inference handler) that resolved just before teardown. That write rejects * with `ERR_STREAM_DESTROYED`, and because the response write is internal to * vscode-jsonrpc and awaited by nobody, the rejection surfaces as an unhandled * rejection. The writer still fires its `error` event (forwarded to * {@link MessageConnection.onError}), so swallowing the rejected promise during * teardown loses no signal. Outside teardown the flag stays `false`, so write * failures propagate normally and in-flight requests still fail fast. */ class TeardownResilientStreamMessageWriter extends StreamMessageWriter { public suppressWriteErrors = false; public override async write(msg: Message): Promise { try { await super.write(msg); } catch (error) { if (!this.suppressWriteErrors) { throw error; } } } } export class CopilotClient { private cliStartTimeout: ReturnType | null = null; private cliProcess: ChildProcess | null = null; private ffiHost: FfiRuntimeHost | null = null; private connection: DrainingMessageConnection | null = null; private requestAdapter: ReturnType | null = null; private messageWriter: TeardownResilientStreamMessageWriter | null = null; private connectionClosed: boolean = false; private socket: Socket | null = null; private runtimePort: number | null = null; private actualHost: string = "localhost"; private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected"; /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ private startPromise: Promise | null = null; private sessions: Map = new Map(); private hostExitCallbacks = new Map void>(); private hostSessionFactories = new Map(); private hostHandoffs = new Map< string, { hostId: string; requestedSessionId?: string; controller: AbortController; configs: Map; session?: CopilotSession; released: boolean; onReleased?: AhpHostOptions["onSessionReleased"]; } >(); private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ private connectionConfig: InternalRuntimeConnection; /** Resolved path to the runtime executable (only used for child-process kinds). */ private resolvedCliPath: string | undefined; /** Resolved environment passed to the spawned runtime. */ private resolvedEnv: Record; private options: { workingDirectory: string; logLevel?: string; gitHubToken?: string; useLoggedInUser: boolean; telemetry?: TelemetryConfig; baseDirectory?: string; sessionIdleTimeoutSeconds: number; enableRemoteSessions: boolean; mode: CopilotClientMode; clientInfo?: CopilotClientInfo; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; /** Token sent in `connect`; auto-generated when the SDK spawns its own CLI in TCP mode. */ private effectiveConnectionToken?: string; private onListModels?: () => Promise | ModelInfo[]; private onGetTraceContext?: TraceContextProvider; private modelsCache: ModelInfo[] | null = null; private modelsCacheLock: Promise = Promise.resolve(); private sessionLifecycleHandlers: Set = new Set(); private typedLifecycleHandlers: Map< SessionLifecycleEventType, Set<(event: SessionLifecycleEvent) => void> > = new Map(); private _rpc: ReturnType | null = null; private _internalRpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits private processTransportError: Error | null = null; private negotiatedProtocolVersion: number | null = null; /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; private requestHandler: CopilotRequestHandler | null = null; private extensionLaunchProvider?: ExtensionLaunchProvider; private installationConfirmationHandler?: InstallationConfirmationHandler; private builtinPluginDirectories: string[] = []; private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; private githubTokenProviders = new Map< string, { provider: GitHubTokenProvider; sessionId?: string; committed: boolean } >(); /** * Typed server-scoped RPC methods. * @throws Error if the client is not connected */ get rpc(): ReturnType { if (!this.connection) { throw new Error("Client is not connected. Call start() first."); } if (!this._rpc) { this._rpc = createServerRpc(this.connection); } return this._rpc; } /** * Internal RPC surface (e.g. handshake helpers). Not part of the public API. * @internal */ private get internalRpc(): ReturnType { if (!this.connection) { throw new Error("Client is not connected. Call start() first."); } if (!this._internalRpc) { this._internalRpc = createInternalServerRpc(this.connection); } return this._internalRpc; } private logDebugTiming(message: string, startMs: number): void { const level = this.options.logLevel?.toLowerCase(); if (level === "debug" || level === "all") { process.stderr.write(`[copilot-sdk] ${message}. Elapsed=${Date.now() - startMs}ms\n`); } } private logDebug(message: string): void { const level = this.options.logLevel?.toLowerCase(); if (level === "debug" || level === "all") { process.stderr.write(`[copilot-sdk] ${message}\n`); } } /** * Environment variable that overrides the transport when the caller does not set * {@link CopilotClientOptions.connection}. Accepts `"inprocess"` or `"stdio"` * (case-insensitive); unset preserves the default stdio transport. Any other value * is an error. */ private static readonly DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION"; /** * Resolves the default {@link RuntimeConnection} for the no-connection case, * honoring {@link CopilotClient.DEFAULT_CONNECTION_ENV_VAR}. */ private static resolveDefaultConnection(): RuntimeConnection { const value = process.env[CopilotClient.DEFAULT_CONNECTION_ENV_VAR]; if (!value || value.toLowerCase() === "stdio") { return { kind: "stdio" }; } if (value.toLowerCase() === "inprocess") { return { kind: "inprocess" }; } throw new Error( `Invalid ${CopilotClient.DEFAULT_CONNECTION_ENV_VAR} value '${value}'. ` + `Expected 'inprocess', 'stdio', or unset.` ); } /** * Creates a new CopilotClient instance. * * @param options - Configuration options for the client * * @example * ```typescript * // Default: spawns the bundled runtime over stdio * const client = new CopilotClient(); * * // Connect to an existing runtime * const client = new CopilotClient({ * connection: RuntimeConnection.forUri("localhost:3000"), * }); * * // Spawn the runtime over TCP on a chosen port * const client = new CopilotClient({ * connection: RuntimeConnection.forTcp({ port: 9001 }), * }); * * // Use a custom runtime binary * const client = new CopilotClient({ * connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), * logLevel: "debug", * }); * ``` */ constructor(options: CopilotClientOptions = {}) { // Resolve the connection mode. `_internalConnection` is set by // `joinSession()` to opt into the parent-process stdio path; consumers // should always go through the public `connection` field. const conn: InternalRuntimeConnection = options._internalConnection ?? options.connection ?? CopilotClient.resolveDefaultConnection(); if ( conn.kind === "uri" && (options.gitHubToken !== undefined || options.useLoggedInUser !== undefined) ) { throw new Error( "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } if (conn.kind === "inprocess" && options.workingDirectory !== undefined) { throw new Error( "workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " + "transport hosts the runtime in this process, so honoring it would require mutating the " + "shared process-global cwd. Change the host process's working directory before " + "constructing the client instead." ); } if (conn.kind === "inprocess" && options.env !== undefined) { throw new Error( "env is not supported with RuntimeConnection.forInProcess(): 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 (conn.kind === "inprocess" && options.telemetry !== undefined) { throw new Error( "telemetry is not supported with RuntimeConnection.forInProcess(): 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." ); } if ( (conn.kind === "stdio" || conn.kind === "tcp") && conn.env !== undefined && options.env !== undefined ) { throw new Error( "Set environment variables via either the client-level env option or the connection's env " + "(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " + "child-process transports." ); } if (conn.kind === "tcp" && conn.connectionToken !== undefined) { if (typeof conn.connectionToken !== "string" || conn.connectionToken.length === 0) { throw new Error("connectionToken must be a non-empty string"); } } this.connectionConfig = conn; if (options.sessionFs) { this.validateSessionFsConfig(options.sessionFs); } if (options.builtinPluginDirectories) { for (const path of options.builtinPluginDirectories) { if (!isAbsolute(path)) { throw new Error( `builtinPluginDirectories must contain only absolute paths: ${path}` ); } } this.builtinPluginDirectories = [...options.builtinPluginDirectories]; } // Pre-parse the URI host/port and mark as external if applicable. if (conn.kind === "uri") { const { host, port } = this.parseCliUrl(conn.url); this.actualHost = host; this.runtimePort = port; this.isExternalServer = true; } else if (conn.kind === "parent-process") { this.isExternalServer = true; } // Effective TCP connection token: explicit, else auto-generated when we // spawn our own runtime over TCP, else undefined. if (conn.kind === "tcp") { this.effectiveConnectionToken = conn.connectionToken ?? randomUUID(); } else if (conn.kind === "uri") { this.effectiveConnectionToken = conn.connectionToken; } this.onListModels = options.onListModels; this.onGetTraceContext = options.onGetTraceContext; this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; this.extensionLaunchProvider = options.extensionLaunchProvider; this.installationConfirmationHandler = options.installationConfirmationHandler; this.onGitHubTelemetry = options.onGitHubTelemetry; this.setupClientGlobalHandlers(); // Connection-level env (child-process transports only) takes precedence // over the client-level env, which falls back to the ambient process env. // The constructor guard above rejects setting both, so at most one of the // first two is defined. Mirrors .NET/Python precedence. const connEnv: Record | undefined = conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined; const effectiveEnv = connEnv ?? options.env ?? process.env; this.resolvedEnv = effectiveEnv; if (conn.kind === "stdio" || conn.kind === "tcp") { const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH; if (explicitCliPath) { this.resolvedCliPath = explicitCliPath; } } // Collect extra CLI args from the connection variant (if any). const connArgs: readonly string[] = conn.kind === "stdio" || conn.kind === "tcp" ? (conn.args ?? []) : []; this.connectionExtraArgs = [...connArgs]; this.options = { workingDirectory: options.workingDirectory ?? process.cwd(), logLevel: options.logLevel, gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. useLoggedInUser: options.useLoggedInUser ?? (options.gitHubToken ? false : true), telemetry: options.telemetry, baseDirectory: options.baseDirectory, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, enableRemoteSessions: options.enableRemoteSessions ?? false, mode: options.mode ?? "copilot-cli", clientInfo: options.clientInfo, }; // Empty mode: validate at construction time that the app supplied a // per-session persistence location. The runtime is mode-agnostic, so // without this check it would silently fall back to ~/.copilot, which // defeats the point of empty mode for multi-tenant scenarios. if (this.options.mode === "empty") { const hasPersistence = this.options.baseDirectory !== undefined || this.sessionFsConfig !== null || // External runtimes manage their own persistence layer; the SDK // can't enforce it from here. conn.kind === "uri" || conn.kind === "parent-process"; if (!hasPersistence) { throw new Error( "CopilotClient was created with mode: 'empty' but neither " + "'baseDirectory' nor 'sessionFs' was set. Empty mode requires " + "an explicit per-session persistence location; pick one." ); } } } private connectionExtraArgs: string[] = []; /** * Parse CLI URL into host and port * Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port" */ private parseCliUrl(url: string): { host: string; port: number } { // Remove protocol if present const cleanUrl = url.replace(/^https?:\/\//, ""); // Check if it's just a port number if (/^\d+$/.test(cleanUrl)) { return { host: "localhost", port: parseInt(cleanUrl, 10) }; } // Handle the canonical bracketed IPv6 host:port form without changing // the existing parser behavior for other inputs. const ipv6Match = cleanUrl.match(/^\[([^\]]+)\]:(\d+)$/); if (ipv6Match) { const host = ipv6Match[1]; if (!isIPv6(host)) { throw new Error(`Invalid cliUrl format: ${url}`); } const port = parseInt(ipv6Match[2], 10); if (isNaN(port) || port <= 0 || port > 65535) { throw new Error(`Invalid port in cliUrl: ${url}`); } return { host, port }; } // Parse host:port format const parts = cleanUrl.split(":"); if (parts.length !== 2) { throw new Error( `Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"` ); } const host = parts[0] || "localhost"; const port = parseInt(parts[1], 10); if (isNaN(port) || port <= 0 || port > 65535) { throw new Error(`Invalid port in cliUrl: ${url}`); } return { host, port }; } private validateSessionFsConfig(config: SessionFsConfig): void { if (!config.initialCwd) { throw new Error("sessionFs.initialCwd is required"); } if (!config.sessionStatePath) { throw new Error("sessionFs.sessionStatePath is required"); } if (config.conventions !== "windows" && config.conventions !== "posix") { throw new Error("sessionFs.conventions must be either 'windows' or 'posix'"); } } private setupSessionFs( session: CopilotSession, config: { createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider } ): void { if (!this.sessionFsConfig) { return; } if (!config.createSessionFsProvider) { throw new Error( "createSessionFsProvider is required in session config when sessionFs is enabled in client options." ); } const provider = config.createSessionFsProvider(session); if (this.sessionFsConfig.capabilities?.sqlite && !provider.sqlite) { throw new Error( "SessionFsConfig declares capabilities.sqlite but the provider does not implement sqlite." ); } session.clientSessionApis.sessionFs = createSessionFsAdapter(provider); } private setupClientGlobalHandlers(): void { const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; handlers.host = { exited: async (exit) => { this.handleHostExit(exit); }, }; handlers.extensionLaunchProvider = this.extensionLaunchProvider; if (this.requestHandler) { this.requestAdapter = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { return undefined; } this._rpc ??= createServerRpc(this.connection); return this._rpc; }); handlers.llmInference = this.requestAdapter; } if (this.onGitHubTelemetry) { const onGitHubTelemetry = this.onGitHubTelemetry; handlers.gitHubTelemetry = { event: async (notification) => { try { await onGitHubTelemetry(notification); } catch { // Ignore handler errors } }, }; } handlers.gitHubToken = { getToken: (params) => this.acquireGitHubToken(params), }; this.clientGlobalHandlers = handlers; } private async acquireGitHubToken( params: GitHubTokenAcquireRequest ): Promise { const registration = this.githubTokenProviders.get(params.registrationId); if (!registration) { throw new Error( `No GitHub token provider registered for registration ID "${params.registrationId}"` ); } return await registration.provider({ host: params.host, sessionId: params.sessionId ?? registration.sessionId, reason: params.reason, }); } private registerGitHubTokenProvider( provider: GitHubTokenProvider | undefined, sessionId?: string ): string | undefined { if (!provider) { return undefined; } const registrationId = randomUUID(); this.githubTokenProviders.set(registrationId, { provider, sessionId, committed: false }); return registrationId; } private assignGitHubTokenProvider(registrationId: string | undefined, sessionId: string): void { if (!registrationId) { return; } const registration = this.githubTokenProviders.get(registrationId); if (registration) { registration.sessionId = sessionId; } } private commitGitHubTokenProvider(sessionId: string, registrationId?: string): void { for (const [candidateId, registration] of this.githubTokenProviders) { if (registration.sessionId === sessionId && registration.committed) { this.githubTokenProviders.delete(candidateId); } } const registration = registrationId ? this.githubTokenProviders.get(registrationId) : undefined; if (registration) { registration.sessionId = sessionId; registration.committed = true; } } /** * Starts the CLI 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 the first time you create or resume a session. * * @returns A promise that resolves when the connection is established * @throws Error if the server fails to start or the connection fails * * @example * ```typescript * const client = new CopilotClient(); * await client.start(); * // Now ready to create sessions * ``` */ async start(): Promise { if (this.state === "connected") { return; } // Concurrent callers share one in-progress start instead of each spawning a CLI. if (this.startPromise) { return this.startPromise; } this.startPromise = this.doStart(); try { await this.startPromise; } finally { this.startPromise = null; } } private async doStart(): Promise { this.forceStopping = false; this.connectionClosed = false; this.processTransportError = null; this.state = "connecting"; try { // Only start CLI server process if not connecting to external server if (this.connectionConfig.kind === "inprocess") { await this.startInProcessFfi(); } else if (!this.isExternalServer) { await this.startCLIServer(); } // Connect to the server await this.connectToServer(); // Verify protocol version compatibility await this.verifyProtocolVersion(); if (this.extensionLaunchProvider) { await this.rpc.registerExtensionLaunchProvider(); } if (this.builtinPluginDirectories.length > 0) { try { await this.connection!.sendRequest("plugins.builtin.set", { paths: this.builtinPluginDirectories, }); } catch (error) { await this.forceStop(); throw error; } } // If a session filesystem provider was configured, register it if (this.sessionFsConfig) { await this.connection!.sendRequest("sessionFs.setProvider", { initialCwd: this.sessionFsConfig.initialCwd, sessionStatePath: this.sessionFsConfig.sessionStatePath, conventions: this.sessionFsConfig.conventions, capabilities: this.sessionFsConfig.capabilities, }); } // If a request handler was configured, register it. The runtime // will then route outbound model HTTP requests through the // registered handler for the duration of each session. if (this.requestHandler) { await this.connection!.sendRequest("llmInference.setProvider", {}); } this.state = "connected"; } catch (error) { const startupError = this.processTransportError ?? error; await this.forceStop(); this.state = "error"; throw startupError; } } /** * Stops the CLI server and closes all active sessions. * * This method performs graceful cleanup: * 1. Closes all active sessions (releases in-memory resources) * 2. Requests runtime shutdown for SDK-owned CLI processes * 3. Closes the JSON-RPC connection * 4. Signals EOF to an owned stdio process and waits for host cleanup, then * terminates the process if it does not exit within the shutdown timeout * * Note: session data on disk is preserved, so sessions can be resumed later. * To permanently remove session data before stopping, call * {@link deleteSession} for each session first. * * @returns A promise that resolves with an array of errors encountered during cleanup. * An empty array indicates all cleanup succeeded. * * @example * ```typescript * const errors = await client.stop(); * if (errors.length > 0) { * console.error("Cleanup errors:", errors); * } * ``` */ async stop(): Promise { const errors: Error[] = []; // Disconnect all active sessions with retry logic const activeSessions = [...this.sessions.values()]; // TEMPORARY: over the in-process (FFI) transport the runtime shares this // process, so a turn still running when the runtime disposes the session // can leave that session's SQLite session.db handle open — it isn't // reclaimed by terminating a child process, so the file stays locked // (Windows) and the session-state directory can't be removed. Abort any // in-flight turn first so it cancels and releases the handle. Best-effort // and idempotent: a session with no active turn is a no-op. Scoped to // in-process only: stdio/tcp runtimes run in a child process that we kill // on shutdown (which frees the handle), and for external servers we don't // own the runtime and aborting would cancel pending work other clients // may still resume. Remove once the runtime cleans up fully on shutdown. if (this.connectionConfig.kind === "inprocess") { await Promise.allSettled(activeSessions.map((session) => session.abort())); } for (const session of activeSessions) { const sessionId = session.sessionId; let lastError: Error | null = null; // Try up to 3 times with exponential backoff for (let attempt = 1; attempt <= 3; attempt++) { try { await session.disconnect(); lastError = null; break; // Success } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < 3) { // Exponential backoff: 100ms, 200ms const delay = 100 * Math.pow(2, attempt - 1); await new Promise((resolve) => setTimeout(resolve, delay)); } } } if (lastError) { errors.push( new Error( `Failed to disconnect session ${sessionId} after 3 attempts: ${lastError.message}` ) ); } } for (const session of activeSessions) { session._markDisconnected(); } this.sessions.clear(); this.githubTokenProviders.clear(); this.requestAdapter?.cancelPending(); // Ask SDK-owned runtimes to flush and clean up before we tear down // their transport/process. External runtimes may be shared, so only // close our connection to them. if ( this.connection && !this.connectionClosed && (this.cliProcess || this.ffiHost) && !this.isExternalServer ) { const runtimeShutdownStart = Date.now(); const shutdownPromise = this.rpc.runtime.shutdown(); void shutdownPromise.catch(() => undefined); try { await withTimeout( shutdownPromise, RUNTIME_SHUTDOWN_TIMEOUT_MS, `runtime.shutdown timed out after ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms` ); this.logDebugTiming( "CopilotClient.stop runtime shutdown complete", runtimeShutdownStart ); } catch (error) { this.logDebugTiming( "CopilotClient.stop runtime shutdown failed", runtimeShutdownStart ); errors.push( new Error( `Failed to gracefully shut down runtime: ${error instanceof Error ? error.message : String(error)}` ) ); } } // Close connection. Suppress writer failures first: tearing down the // transport can reject an in-flight server→client response write with // ERR_STREAM_DESTROYED, which would otherwise surface as an unhandled // rejection. dispose() still rejects any pending requests. if (this.messageWriter) { this.messageWriter.suppressWriteErrors = true; } if (this.connection) { try { this.connection.dispose(); } catch (error) { errors.push( new Error( `Failed to dispose connection: ${error instanceof Error ? error.message : String(error)}` ) ); } this.connection = null; this.messageWriter = null; this._rpc = null; this._internalRpc = null; } // Clear models cache this.modelsCache = null; // Close the TCP socket and wait for the close to complete before returning. if (this.socket) { const socket = this.socket; this.socket = null; try { if (!socket.destroyed) { await new Promise((resolve) => { socket.once("close", () => resolve()); socket.end(); }); } } catch (error) { errors.push( new Error( `Failed to close socket: ${error instanceof Error ? error.message : String(error)}` ) ); } } if (this.cliProcess && !this.isExternalServer) { const child = this.cliProcess; if ( this.connectionConfig.kind === "stdio" && child.stdin && child.exitCode == null && child.signalCode == null ) { const gracefulExitStart = Date.now(); try { // Host telemetry is finalized after transport EOF, not the shutdown RPC. child.stdin.end(); const exited = await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS); this.logDebugTiming( exited ? "CopilotClient.stop graceful stdio exit complete" : "CopilotClient.stop graceful stdio exit timed out; terminating child", gracefulExitStart ); } catch (error) { errors.push( new Error( `Failed to close CLI stdin: ${error instanceof Error ? error.message : String(error)}` ) ); } } try { if (child.exitCode == null && child.signalCode == null) { child.kill(); if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) { errors.push( new Error( `Timed out waiting for CLI process to exit after kill: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms` ) ); } } } catch (error) { errors.push( new Error( `Failed to kill CLI process: ${error instanceof Error ? error.message : String(error)}` ) ); } finally { if (this.cliProcess === child) { this.cliProcess = null; } } } // Tear down the in-process FFI host (closes the native connection and // shuts down the native runtime host) for SDK-owned in-process runtimes. if (this.ffiHost) { const host = this.ffiHost; this.ffiHost = null; try { await host.dispose(); } catch (error) { errors.push( new Error( `Failed to dispose in-process runtime host: ${error instanceof Error ? error.message : String(error)}` ) ); } } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; } this.state = "disconnected"; this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; this.disconnectHosts(); return errors; } /** * Alias for {@link stop} that lets `CopilotClient` participate in `await using` * blocks for automatic cleanup. * * @example * ```typescript * await using client = new CopilotClient(); * const session = await client.createSession({ onPermissionRequest: approveAll }); * await session.sendAndWait("Hello"); * // client.stop() is called automatically when the block exits. * ``` */ async [Symbol.asyncDispose](): Promise { await this.stop(); } /** * Forcefully stops the CLI server without graceful cleanup. * * Use this when {@link stop} fails or takes too long. This method: * - Clears all sessions immediately without destroying them * - Force closes the connection * - Sends SIGKILL to the CLI process (if spawned by this client) * * @returns A promise that resolves when the force stop is complete * * @example * ```typescript * // If normal stop hangs, force stop * const stopPromise = client.stop(); * const timeout = new Promise((_, reject) => * setTimeout(() => reject(new Error("Timeout")), 5000) * ); * * try { * await Promise.race([stopPromise, timeout]); * } catch { * await client.forceStop(); * } * ``` */ async forceStop(): Promise { this.forceStopping = true; this.disconnectHosts(); // Clear sessions immediately without trying to destroy them for (const session of this.sessions.values()) { session._markDisconnected(); } this.sessions.clear(); this.githubTokenProviders.clear(); this.requestAdapter?.cancelPending(); // Force close connection. Suppress writer failures first so teardown // write rejections don't surface as unhandled rejections. if (this.messageWriter) { this.messageWriter.suppressWriteErrors = true; } if (this.connection) { try { this.connection.dispose(); } catch { // Ignore errors during force stop } this.connection = null; this.messageWriter = null; this._rpc = null; this._internalRpc = null; } // Clear models cache this.modelsCache = null; if (this.socket) { try { this.socket.destroy(); // destroy() is more forceful than end() } catch { // Ignore errors } this.socket = null; } // Force kill CLI process (only if we spawned it) if (this.cliProcess && !this.isExternalServer) { try { this.cliProcess.kill("SIGKILL"); } catch { // Ignore errors } this.cliProcess = null; } // Tear down the in-process FFI host (if any). if (this.ffiHost) { const host = this.ffiHost; this.ffiHost = null; try { await host.dispose(); } catch { // Ignore errors during force stop } } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; } this.state = "disconnected"; this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; } /** * 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 method automatically starts the connection. * * @param config - Optional configuration for the session * @returns A promise that resolves with the created session * @throws Error if the client fails to start * * @example * ```typescript * // Basic session * const session = await client.createSession({ onPermissionRequest: approveAll }); * * // Session with model and tools * const session = await client.createSession({ * onPermissionRequest: approveAll, * model: "gpt-4", * tools: [{ * name: "get_weather", * description: "Get weather for a location", * parameters: { type: "object", properties: { location: { type: "string" } } }, * handler: async (args) => ({ temperature: 72 }) * }] * }); * ``` */ /** * Normalizes session-level tool filter options. Converts {@link ToolSet} * instances to plain string arrays, rejects misuse (bare `"*"`) and the * missing-availableTools case in `mode = "empty"`. * * The SDK always sends `toolFilterPrecedence: "excluded"` so callers can * compose include + exclude lists naturally (e.g. "everything matching X * except Y") regardless of mode. Allowlist-precedence is intentionally not * exposed — it's available on the runtime side as a CLI-only concession to * legacy behavior, but SDK consumers always get the composable semantics. * * @internal */ private resolveToolFilterOptions(config: { availableTools?: string[] | ToolSet; excludedTools?: string[] | ToolSet; }): { availableTools: string[] | undefined; excludedTools: string[] | undefined; toolFilterPrecedence: "excluded"; } { const availableTools = toolFilterListToArray(config.availableTools); const excludedTools = toolFilterListToArray(config.excludedTools); validateToolFilterList("availableTools", availableTools); validateToolFilterList("excludedTools", excludedTools); if (this.options.mode === "empty") { if (availableTools === undefined) { throw new Error( "CopilotClient is in mode: 'empty' but the session config did not " + "specify 'availableTools'. Empty mode requires every session to " + "explicitly opt into the tools it wants — e.g. " + "`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`." ); } } return { availableTools, excludedTools, toolFilterPrecedence: "excluded" }; } /** Mode-specific defaults spread under the caller's config (app values win). */ private configDefaultsForMode(): Partial { if (this.options.mode === "empty") { return { enableSessionTelemetry: false, mcpOAuthTokenStorage: "in-memory", skipEmbeddingRetrieval: true, embeddingCacheStorage: "in-memory", enableOnDemandInstructionDiscovery: false, enableFileHooks: false, enableHostGitOperations: false, enableSessionStore: false, enableSkills: false, memory: { enabled: false }, customAgentsLocalOnly: true, }; } return {}; } /** Mode-specific default for enableExperimentalMode. */ private experimentalModeForMode(supplied: boolean | undefined): boolean | undefined { return this.options.mode === "empty" ? (supplied ?? false) : supplied; } /** * Returns the systemMessage config to use, adjusted for the current mode. * In empty mode we ensure the environment_context section is removed * unless the app has already taken control of it. `append` (and * unspecified) mode is promoted to `customize` so we can also strip * environment_context; the caller's `content` is preserved verbatim * because the runtime appends it as additional instructions in both * customize and append modes. */ private getSystemMessageConfigForMode( supplied: SystemMessageConfig | undefined ): SystemMessageConfig | undefined { if (this.options.mode !== "empty") return supplied; if (!supplied) { return { mode: "customize", sections: { environment_context: { action: "remove" } }, }; } switch (supplied.mode) { case "replace": return supplied; case "customize": if (supplied.sections?.environment_context) return supplied; return { ...supplied, sections: { ...supplied.sections, environment_context: { action: "remove" }, }, }; case "append": case undefined: // Promote to customize so we can also strip environment_context. // The runtime appends `content` to additional instructions in // both customize and append modes, so the caller's text is // preserved verbatim. return { mode: "customize", content: supplied.content, sections: { environment_context: { action: "remove" } }, }; } } /** * Mode-specific options applied via session.options.update after create/resume. * * In empty mode, defaults the four overridable feature flags to safe values * (caller values from `config` win). `installedPlugins=[]` is unconditional * in empty mode. `includedBuiltinSkills` defaults to `[]`, but callers can * explicitly allow selected runtime-bundled skills. */ private async updateSessionOptionsForMode( session: CopilotSession, config: SessionConfigBase ): Promise { const patch: SessionUpdateOptionsParams = {}; if (this.options.mode === "empty") { patch.skipCustomInstructions = config.skipCustomInstructions ?? true; patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true; patch.coauthorEnabled = config.coauthorEnabled ?? false; patch.manageScheduleEnabled = config.manageScheduleEnabled ?? false; patch.installedPlugins = []; patch.includedBuiltinSkills = config.includedBuiltinSkills ?? []; } else { if (config.skipCustomInstructions !== undefined) patch.skipCustomInstructions = config.skipCustomInstructions; if (config.customAgentsLocalOnly !== undefined) patch.customAgentsLocalOnly = config.customAgentsLocalOnly; if (config.coauthorEnabled !== undefined) patch.coauthorEnabled = config.coauthorEnabled; if (config.manageScheduleEnabled !== undefined) patch.manageScheduleEnabled = config.manageScheduleEnabled; if (config.includedBuiltinSkills !== undefined) patch.includedBuiltinSkills = config.includedBuiltinSkills; } if (Object.keys(patch).length === 0) { return; } try { await session.rpc.options.update(patch); } catch (e) { // The runtime session exists but the post-create options // patch failed — best-effort disconnect so we don't leak // it (in empty mode it would otherwise keep running with // permissive defaults). try { await session.disconnect(); } catch { // Swallow: original error is the one the caller needs. } throw e; } } async createSession(config: SessionConfig): Promise { if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); } if (!this.connection) { await this.start(); } const modeDefaults = this.configDefaultsForMode(); config = { ...modeDefaults, ...config }; config.customAgentsLocalOnly ??= modeDefaults.customAgentsLocalOnly; config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); // For cloud sessions, let the CLI/server assign the session id and // register the session lazily once the response arrives. For non-cloud // sessions we generate the id client-side (when the caller didn't // supply one) so the session can be registered BEFORE the RPC — the // CLI may issue session-scoped requests (e.g. `sessionFs.writeFile` // for workspace metadata) during `session.create` processing, before // it has sent the response. const callerSessionId = config.sessionId; const useServerGeneratedId = config.cloud != null && callerSessionId == null; const localSessionId = useServerGeneratedId ? undefined : (callerSessionId ?? randomUUID()); const toolFilterOptions = this.resolveToolFilterOptions(config); const gitHubTokenProviderRegistrationId = this.registerGitHubTokenProvider( config.gitHubTokenProvider, localSessionId ); // Strip non-serializable bearerTokenProvider callbacks from provider configs, // replacing them with a wire flag; keep the callbacks for session-side // registration so the runtime can call back to acquire tokens. const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, callbacks: bearerTokenCallbacks, } = extractBearerTokenProviders(config.provider, config.providers); // Extract transform callbacks from system message config before serialization. const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( config.systemMessage ); // Creates the session object, wires up handlers, and registers it in // the sessions map. const initializeSession = (sessionId: string): CopilotSession => { const s = new CopilotSession( sessionId, this.connection!, undefined, this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, managedSettingsEnabled: config.enableManagedSettings === true || config.managedSettings !== undefined, onDisconnected: gitHubTokenProviderRegistrationId === undefined ? undefined : () => this.githubTokenProviders.delete( gitHubTokenProviderRegistrationId ), } ); s.registerTools(config.tools); s.registerCanvases(config.canvases); s.registerCommands(config.commands); if (bearerTokenCallbacks.size > 0) { s.registerBearerTokenProviders(bearerTokenCallbacks); } s.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { s.registerUserInputHandler(config.onUserInputRequest); } if (config.onElicitationRequest) { s.registerElicitationHandler(config.onElicitationRequest); } if (config.onExitPlanModeRequest) { s.registerExitPlanModeHandler(config.onExitPlanModeRequest); } if (config.onAutoModeSwitchRequest) { s.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest); } if (config.hooks) { s.registerHooks(config.hooks); } if (transformCallbacks) { s.registerTransformCallbacks(transformCallbacks); } if (config.onEvent) { s.on(config.onEvent); } this.sessions.set(sessionId, s); this.setupSessionFs(s, config); return s; }; let session: CopilotSession | undefined; let registeredId: string | undefined; // Pre-register non-cloud sessions BEFORE issuing the RPC so any // session-scoped requests the CLI emits during `session.create` // processing (e.g. sessionFs.writeFile for workspace metadata) can be // routed to the correct handlers. if (localSessionId !== undefined) { try { session = initializeSession(localSessionId); registeredId = localSessionId; } catch (error) { if (gitHubTokenProviderRegistrationId !== undefined) { this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId); } throw error; } } try { const response = await this.connection!.sendRequest("session.create", { ...(await getTraceContext(this.onGetTraceContext)), model: config.model, allowedModels: config.allowedModels, sessionId: localSessionId, clientName: config.clientName, reasoningEffort: config.reasoningEffort, reasoningSummary: config.reasoningSummary, isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode), contextTier: config.contextTier, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, metadata: tool.metadata, isTerminal: tool.isTerminal, })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description ?? "", })), systemMessage: wireSystemMessage, availableTools: toolFilterOptions.availableTools, excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, excludedBuiltinAgents: config.excludedBuiltinAgents, provider: bearerWireProvider, capi: config.capi, providers: bearerWireProviders, models: config.models, enableSessionTelemetry: config.enableSessionTelemetry, enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: !!config.onPermissionRequest, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, additionalDirectories: config.additionalDirectories, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, ...(this.onGitHubTelemetry != null ? { enableGitHubTelemetryForwarding: true } : {}), mcpServers: toWireMcpServers(config.mcpServers), ...(config.diagnostics !== undefined ? { diagnostics: config.diagnostics } : {}), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, authClientIdMetadataUrl: config.authClientIdMetadataUrl, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), customAgentsLocalOnly: config.customAgentsLocalOnly, defaultAgent: config.defaultAgent, agent: config.agent, configDir: config.configDirectory, enableConfigDiscovery: config.enableConfigDiscovery, refreshCustomInstructions: config.refreshCustomInstructions, skipEmbeddingRetrieval: config.skipEmbeddingRetrieval, embeddingCacheStorage: config.embeddingCacheStorage, organizationCustomInstructions: config.organizationCustomInstructions, enableOnDemandInstructionDiscovery: config.enableOnDemandInstructionDiscovery, enableFileHooks: config.enableFileHooks, enableHostGitOperations: config.enableHostGitOperations, enableSessionStore: config.enableSessionStore, enableSkills: config.enableSkills, skillDirectories: config.skillDirectories, pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, disabledSkills: config.disabledSkills, disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, memory: config.memory, gitHubToken: config.gitHubToken, gitHubTokenProviderRegistrationId, remoteSession: config.remoteSession, cloud: config.cloud, featureFlags: config.featureFlags, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, }); const { sessionId: returnedSessionId, workspacePath, capabilities, } = response as { sessionId: string; workspacePath?: string; capabilities?: SessionCapabilities; }; if (!returnedSessionId) { throw new Error("session.create response did not include a sessionId"); } if (localSessionId !== undefined && localSessionId !== returnedSessionId) { throw new Error( `session.create returned sessionId ${returnedSessionId} but the caller requested ${localSessionId}` ); } if (session === undefined) { // Cloud / server-assigned path: register the session now that // the CLI has told us which id it chose. session = initializeSession(returnedSessionId); registeredId = returnedSessionId; } this.assignGitHubTokenProvider(gitHubTokenProviderRegistrationId, returnedSessionId); if (config.onMcpAuthRequest) { await this.connection!.sendRequest("session.eventLog.registerInterest", { sessionId: returnedSessionId, eventType: "mcp.oauth_required", }); } session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); await this.updateSessionOptionsForMode(session, config); this.commitGitHubTokenProvider(returnedSessionId, gitHubTokenProviderRegistrationId); } catch (e) { session?._markDisconnected(); if (registeredId !== undefined) { this.sessions.delete(registeredId); } if (gitHubTokenProviderRegistrationId !== undefined) { this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId); } throw e; } for (const entry of this.hostHandoffs.values()) { if (entry.requestedSessionId === session.sessionId) { entry.configs.set(session, config); } } return session; } /** * Resumes an existing conversation session by its ID. * * This allows you to continue a previous conversation, maintaining all * conversation history. The session must have been previously created * and not deleted. * * @param sessionId - The ID of the session to resume * @param config - Optional configuration for the resumed session * @returns A promise that resolves with the resumed session * @throws Error if the session does not exist or the client is not connected * * @example * ```typescript * // Resume a previous session * const session = await client.resumeSession("session-123", { onPermissionRequest: approveAll }); * * // Resume with new tools * const session = await client.resumeSession("session-123", { * onPermissionRequest: approveAll, * tools: [myNewTool] * }); * ``` */ async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { return this.resumeSessionInternal(sessionId, config); } /** @internal */ async resumeSessionForExtension( sessionId: string, config: ResumeSessionConfig, contributions: ExtensionOrchestrationContributions = {}, extensionOptions?: ExtensionJoinOptions ): Promise { return this.resumeSessionInternal(sessionId, config, contributions, extensionOptions); } private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, contributions: ExtensionOrchestrationContributions = {}, extensionOptions?: ExtensionJoinOptions ): Promise { const { workflows } = contributions; if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); } if (!this.connection) { await this.start(); } // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. const session = new CopilotSession( sessionId, this.connection!, undefined, this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, managedSettingsEnabled: config.enableManagedSettings === true || config.managedSettings !== undefined, } ); session.registerTools(config.tools); session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerWorkflows(workflows); const { wireProvider: bearerWireProvider, wireProviders: bearerWireProviders, callbacks: bearerTokenCallbacks, } = extractBearerTokenProviders(config.provider, config.providers); if (bearerTokenCallbacks.size > 0) { session.registerBearerTokenProviders(bearerTokenCallbacks); } session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } if (config.onElicitationRequest) { session.registerElicitationHandler(config.onElicitationRequest); } if (config.onExitPlanModeRequest) { session.registerExitPlanModeHandler(config.onExitPlanModeRequest); } if (config.onAutoModeSwitchRequest) { session.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest); } if (config.hooks) { session.registerHooks(config.hooks); } const modeDefaults = this.configDefaultsForMode(); config = { ...modeDefaults, ...config }; config.customAgentsLocalOnly ??= modeDefaults.customAgentsLocalOnly; config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( config.systemMessage ); if (transformCallbacks) { session.registerTransformCallbacks(transformCallbacks); } if (config.onEvent) { session.on(config.onEvent); } this.sessions.set(sessionId, session); this.setupSessionFs(session, config); const toolFilterOptions = this.resolveToolFilterOptions(config); const gitHubTokenProviderRegistrationId = this.registerGitHubTokenProvider( config.gitHubTokenProvider, sessionId ); if (gitHubTokenProviderRegistrationId !== undefined) { session._setOnDisconnected(() => this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId) ); } try { const response = await this.connection!.sendRequest("session.resume", { allowedModels: config.allowedModels, ...(await getTraceContext(this.onGetTraceContext)), sessionId, clientName: config.clientName, model: config.model, reasoningEffort: config.reasoningEffort, reasoningSummary: config.reasoningSummary, isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode), contextTier: config.contextTier, systemMessage: wireSystemMessage, availableTools: toolFilterOptions.availableTools, excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, enableSessionTelemetry: config.enableSessionTelemetry, excludedBuiltinAgents: config.excludedBuiltinAgents, enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, metadata: tool.metadata, isTerminal: tool.isTerminal, })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), workflows: workflows?.map((workflow) => workflow.meta), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description ?? "", })), provider: bearerWireProvider, capi: config.capi, providers: bearerWireProviders, models: config.models, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, additionalDirectories: config.additionalDirectories, configDir: config.configDirectory, enableConfigDiscovery: config.enableConfigDiscovery, skipEmbeddingRetrieval: config.skipEmbeddingRetrieval, embeddingCacheStorage: config.embeddingCacheStorage, organizationCustomInstructions: config.organizationCustomInstructions, enableOnDemandInstructionDiscovery: config.enableOnDemandInstructionDiscovery, enableFileHooks: config.enableFileHooks, enableHostGitOperations: config.enableHostGitOperations, enableSessionStore: config.enableSessionStore, enableSkills: config.enableSkills, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, ...(this.onGitHubTelemetry != null ? { enableGitHubTelemetryForwarding: true } : {}), mcpServers: toWireMcpServers(config.mcpServers), ...(config.diagnostics !== undefined ? { diagnostics: config.diagnostics } : {}), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, authClientIdMetadataUrl: config.authClientIdMetadataUrl, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), customAgentsLocalOnly: config.customAgentsLocalOnly, defaultAgent: config.defaultAgent, agent: config.agent, skillDirectories: config.skillDirectories, pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, disabledSkills: config.disabledSkills, disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, memory: config.memory, disableResume: config.suppressResumeEvent, continuePendingWork: config.continuePendingWork, gitHubToken: config.gitHubToken, gitHubTokenProviderRegistrationId, remoteSession: config.remoteSession, openCanvases: config.openCanvases, featureFlags: config.featureFlags, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, ...(extensionOptions?.requestedEnvironmentVariables ? { requestedEnvironmentVariables: extensionOptions.requestedEnvironmentVariables, } : {}), }); // The host answers an approved environment request with the resolved // values, and this method consumes the response, so the grant has to be // applied here — no caller ever sees it. Only the names the user // approved may reach the extension, so a host that answers with // anything extra cannot widen the grant. if (extensionOptions?.requestedEnvironmentVariables) { const requested = new Set(extensionOptions.requestedEnvironmentVariables); const { grantedEnvironmentVariables } = response as { grantedEnvironmentVariables?: Record; }; for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) { if (requested.has(name)) { process.env[name] = value; } } } const { workspacePath, capabilities, openCanvases } = response as { sessionId: string; workspacePath?: string; capabilities?: SessionCapabilities; openCanvases?: OpenCanvasInstance[]; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); session.setOpenCanvases(openCanvases ?? []); if (config.onMcpAuthRequest) { await this.connection!.sendRequest("session.eventLog.registerInterest", { sessionId, eventType: "mcp.oauth_required", }); } await this.updateSessionOptionsForMode(session, config); this.commitGitHubTokenProvider(sessionId, gitHubTokenProviderRegistrationId); } catch (e) { session._markDisconnected(); this.sessions.delete(sessionId); if (gitHubTokenProviderRegistrationId !== undefined) { this.githubTokenProviders.delete(gitHubTokenProviderRegistrationId); } throw e; } for (const entry of this.hostHandoffs.values()) { if (entry.requestedSessionId === session.sessionId) { entry.configs.set(session, { ...config, sessionId }); } } return session; } /** * Start a complete AHP listener hosted in-process by this runtime. * * This connection owns the host. Disposing it or disconnecting the client * stops the listener without deleting sessions. The host uses a separate * SDK connection to the same runtime, not another runtime process. * Only one AHP host may own the catalog in an effective Copilot home * at a time. Other SDK clients and sessions remain usable in that home. * Select localServer, githubEnvironment, or both. An empty localServer * selects 127.0.0.1 on an available port. `onExit` runs at most once; owner disconnection cannot * acknowledge listener cleanup over the disconnected transport. * * @experimental */ async startAhpHost(options: AhpHostOptions): Promise { if (!options?.localServer && !options?.githubEnvironment) { throw new Error("At least one of localServer or githubEnvironment is required"); } if (this.state !== "connected") { await this.start(); } const rpc = this.rpc; const hostId = randomUUID(); const { localServer, githubEnvironment, onExit, createSession, resumeSession, onSessionReleased, } = options; if (createSession || resumeSession) { this.hostSessionFactories.set(hostId, { createSession, resumeSession, onSessionReleased, }); } if (onExit) { this.hostExitCallbacks.set(hostId, onExit); } try { const info = await rpc.host.start({ hostId, localServer, githubEnvironment, sessionFactory: createSession ? true : undefined, resumeFactory: resumeSession ? true : undefined, }); return new AhpHost( info, async () => { await rpc.host.dispose({ hostId }); }, (sessionId) => rpc.host.publishSession({ hostId, sessionId }) ); } catch (error) { this.hostExitCallbacks.delete(hostId); this.releaseHostSessions(hostId); throw error; } } private handleHostExit(exit: AhpHostExit): void { this.releaseHostSessions(exit.hostId); const onExit = this.hostExitCallbacks.get(exit.hostId); if (onExit) { this.hostExitCallbacks.delete(exit.hostId); const reportError = (error: unknown) => { console.error("AHP host exit callback failed", { hostId: exit.hostId, error }); }; try { void Promise.resolve(onExit(exit)).catch(reportError); } catch (error) { reportError(error); } } } private disconnectHosts(): void { for (const hostId of new Set([ ...this.hostExitCallbacks.keys(), ...this.hostSessionFactories.keys(), ])) { this.handleHostExit({ hostId, reason: "ownerDisconnected", error: "Owner connection closed; runtime cleanup cannot be acknowledged on this connection.", }); } } private releaseHostSessions(hostId: string): void { this.hostSessionFactories.delete(hostId); for (const [handoffId, entry] of this.hostHandoffs) { if (entry.hostId === hostId) this.releaseHostSession(handoffId); } } private releaseHostSession(handoffId: string): void { const entry = this.hostHandoffs.get(handoffId); if (!entry) return; this.hostHandoffs.delete(handoffId); entry.released = true; entry.configs.clear(); entry.controller.abort(); if (entry.session) this.notifyHostSessionReleased(entry); } private notifyHostSessionReleased(entry: { session?: CopilotSession; onReleased?: AhpHostOptions["onSessionReleased"]; }): void { const { session, onReleased } = entry; entry.session = undefined; if (session && onReleased) { try { void Promise.resolve(onReleased(session)).catch(() => { console.error("AHP session release callback failed"); }); } catch { console.error("AHP session release callback failed"); } } } private async createHostSession(params: { hostId: string; handoffId: string; resume?: boolean; config: Omit & { configDir?: string }; }): Promise<{ sessionId: string }> { const options = this.hostSessionFactories.get(params.hostId); if ( !(params.resume ? options?.resumeSession : options?.createSession) || this.hostHandoffs.has(params.handoffId) ) { throw new Error("AHP session factory is unavailable or handoff already exists"); } const entry: { hostId: string; requestedSessionId?: string; controller: AbortController; configs: Map; session?: CopilotSession; released: boolean; onReleased?: AhpHostOptions["onSessionReleased"]; } = { hostId: params.hostId, requestedSessionId: params.config.sessionId, controller: new AbortController(), configs: new Map(), released: false, onReleased: options!.onSessionReleased, }; this.hostHandoffs.set(params.handoffId, entry); const { configDir, ...config } = params.config; if (configDir !== undefined) config.configDirectory = configDir; const expected = structuredClone(config); const cancelled = new Promise((_resolve, reject) => { entry.controller.signal.addEventListener( "abort", () => { reject(new Error("AHP session handoff ended")); }, { once: true } ); }); const materialized = Promise.resolve() .then(() => { if (params.resume) { const { sessionId, ...resumeConfig } = config; if (!sessionId) throw new Error("AHP resume requires sessionId"); return options!.resumeSession!({ sessionId, config: resumeConfig, signal: entry.controller.signal, }); } return options!.createSession!({ config, signal: entry.controller.signal, }); }) .then((session) => { entry.session = session; const actual = entry.configs.get(session); entry.configs.clear(); if (entry.released) { this.notifyHostSessionReleased(entry); throw new Error("AHP session handoff ended"); } if ( this.sessions.get(session.sessionId) !== session || session.sessionId !== expected.sessionId ) { throw new Error( "AHP callback must return the requested session from this client" ); } // Only host-selected keys constrain materialization. App prompt, // filters and tools remain untouched when the host did not select them. const contains = (value: unknown, required: unknown): boolean => { if (required && typeof required === "object" && !Array.isArray(required)) { return ( !!value && typeof value === "object" && Object.entries(required).every(([key, child]) => contains((value as Record)[key], child) ) ); } return JSON.stringify(value) === JSON.stringify(required); }; // Retained originals have no new materialization snapshot. Their // resident workspace is checked by the runtime; do not reconfigure them. if ((!params.resume || actual !== undefined) && !contains(actual, expected)) { throw new Error( "AHP callback must preserve the supplied session configuration" ); } return { sessionId: session.sessionId }; }); try { return await Promise.race([materialized, cancelled]); } catch (error) { this.releaseHostSession(params.handoffId); throw error; } } /** * Sends a ping request to the server to verify connectivity. * * @param message - Optional message to include in the ping * @returns A promise that resolves with the ping response containing the message and timestamp * @throws Error if the client is not connected * * @example * ```typescript * const response = await client.ping("health check"); * console.log(`Server responded at ${new Date(response.timestamp)}`); * ``` */ async ping( message?: string ): Promise<{ message: string; timestamp: string; protocolVersion?: number }> { if (!this.connection) { throw new Error("Client not connected"); } const result = await this.connection.sendRequest("ping", { message }); return result as { message: string; timestamp: string; protocolVersion?: number; }; } /** * Get CLI status including version and protocol information */ async getStatus(): Promise { if (!this.connection) { throw new Error("Client not connected"); } const result = await this.connection.sendRequest("status.get", {}); return result as GetStatusResponse; } /** * Get current authentication status */ async getAuthStatus(): Promise { if (!this.connection) { throw new Error("Client not connected"); } const result = await this.connection.sendRequest("auth.getStatus", {}); return result as GetAuthStatusResponse; } /** * List available models with their metadata. * * If an `onListModels` handler was provided in the client options, * it is called instead of querying the CLI server. * * Results are cached after the first successful call to avoid rate limiting. * The cache is cleared when the client disconnects. * * @throws Error if not connected (when no custom handler is set) */ async listModels(): Promise { // Use promise-based locking to prevent race condition with concurrent calls await this.modelsCacheLock; let resolveLock: () => void; this.modelsCacheLock = new Promise((resolve) => { resolveLock = resolve; }); try { // Check cache (already inside lock) if (this.modelsCache !== null) { return [...this.modelsCache]; // Return a copy to prevent cache mutation } let models: ModelInfo[]; if (this.onListModels) { // Use custom handler instead of CLI RPC models = await this.onListModels(); } else { if (!this.connection) { throw new Error("Client not connected"); } // Cache miss - fetch from backend while holding lock const result = await this.connection.sendRequest("models.list", {}); const response = result as { models: ModelInfo[] }; models = response.models; // Normalize model capabilities — some models (e.g. embedding models) // may omit 'supports' or 'limits' in their capabilities. for (const model of models) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const m = model as any; if (!m.capabilities) { m.capabilities = { supports: {}, limits: { max_context_window_tokens: 0 }, }; } else { if (!m.capabilities.supports) m.capabilities.supports = {}; if (!m.capabilities.limits) { m.capabilities.limits = { max_context_window_tokens: 0 }; } else if (m.capabilities.limits.max_context_window_tokens === undefined) { m.capabilities.limits.max_context_window_tokens = 0; } } } } // Update cache before releasing lock (copy to prevent external mutation) this.modelsCache = [...models]; return [...models]; // Return a copy to prevent cache mutation } finally { resolveLock!(); } } /** * Send the `connect` handshake (carrying the optional token) and verify the * server's protocol version. Falls back to `ping` against legacy servers * that don't implement `connect`. */ private async verifyProtocolVersion(): Promise { if (!this.connection) { throw new Error("Client not connected"); } const maxVersion = getSdkProtocolVersion(); const raceAgainstExit = (p: Promise): Promise => this.processExitPromise ? Promise.race([p, this.processExitPromise]) : p; let serverVersion: number | undefined; try { const connectParams: { token?: string; enableGitHubTelemetryForwarding?: boolean; clientInfo?: ConnectClientInfo; supportedTaskKinds?: TaskKind[]; } = { token: this.effectiveConnectionToken, supportedTaskKinds: ["agent", "client", "shell"], }; // Opt in to GitHub telemetry forwarding at the connection level when a // handler is registered (mirrors the runtime, which reads this flag on the // `connect` handshake so the first session's un-replayable `session.start` // event is forwarded). Also sent on session.create/resume for older CLIs. if (this.onGitHubTelemetry != null) { connectParams.enableGitHubTelemetryForwarding = true; } // Declare the integrating application's identity so the runtime attributes // the telemetry it emits on this connection to a consistent surface // instead of its own build. Empty fields are dropped, and an // all-empty identity is omitted entirely. const clientInfo = clientInfoToWire(this.options.clientInfo); if (clientInfo != null) { connectParams.clientInfo = clientInfo; } const result = await raceAgainstExit(this.internalRpc.connect(connectParams)); serverVersion = result.protocolVersion; } catch (err) { if ( err instanceof ResponseError && (err.code === ErrorCodes.MethodNotFound || err.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. serverVersion = (await raceAgainstExit(this.ping())).protocolVersion; } else { throw err; } } if (serverVersion === undefined) { throw new Error( `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` + `Please update your server to ensure compatibility.` ); } if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > maxVersion) { throw new Error( `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server reports version ${serverVersion}. ` + `Please update your SDK or server to ensure compatibility.` ); } this.negotiatedProtocolVersion = serverVersion; } /** * Gets the ID of the most recently updated session. * * This is useful for resuming the last conversation when the session ID * was not stored. * * @returns A promise that resolves with the session ID, or undefined if no sessions exist * @throws Error if the client is not connected * * @example * ```typescript * const lastId = await client.getLastSessionId(); * if (lastId) { * const session = await client.resumeSession(lastId, { onPermissionRequest: approveAll }); * } * ``` */ async getLastSessionId(): Promise { if (!this.connection) { throw new Error("Client not connected"); } const response = await this.connection.sendRequest("session.getLastId", {}); return (response as { sessionId?: string }).sessionId; } /** * Permanently deletes a session and all its data from disk, including * conversation history, planning state, and artifacts. * * Unlike {@link CopilotSession.disconnect}, which only releases in-memory * resources and preserves session data for later resumption, this method * is irreversible. The session cannot be resumed after deletion. * * @param sessionId - The ID of the session to delete * @returns A promise that resolves when the session is deleted * @throws Error if the session does not exist or deletion fails * * @example * ```typescript * await client.deleteSession("session-123"); * ``` */ async deleteSession(sessionId: string): Promise { if (!this.connection) { throw new Error("Client not connected"); } const response = await this.connection.sendRequest("session.delete", { sessionId, }); const { success, error } = response as { success: boolean; error?: string }; if (!success) { throw new Error(`Failed to delete session ${sessionId}: ${error || "Unknown error"}`); } const session = this.sessions.get(sessionId); this.sessions.delete(sessionId); session?._runOnDisconnected(); } /** * List all available sessions. * * @param filter - Optional filter to limit returned sessions by context fields * * @example * // List all sessions * const sessions = await client.listSessions(); * * @example * // List sessions for a specific repository * const sessions = await client.listSessions({ repository: "owner/repo" }); */ async listSessions(filter?: SessionListFilter): Promise { if (!this.connection) { throw new Error("Client not connected"); } // Transform filter to wire format (workingDirectory → cwd) let wireFilter: Record | undefined; if (filter) { const { workingDirectory, ...rest } = filter; wireFilter = { ...rest, cwd: workingDirectory }; } const response = await this.connection.sendRequest("session.list", { filter: wireFilter, }); const { sessions } = response as { sessions: Array<{ sessionId: string; startTime: string; modifiedTime: string; summary?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }>; }; return sessions.map(CopilotClient.toSessionMetadata); } /** * Gets 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 undefined if the session is not found. * * @param sessionId - The ID of the session to look up * @returns A promise that resolves with the session metadata, or undefined if not found * @throws Error if the client is not connected * * @example * ```typescript * const metadata = await client.getSessionMetadata("session-123"); * if (metadata) { * console.log(`Session started at: ${metadata.startTime}`); * } * ``` */ async getSessionMetadata(sessionId: string): Promise { if (!this.connection) { throw new Error("Client not connected"); } const response = await this.connection.sendRequest("session.getMetadata", { sessionId }); const { session } = response as { session?: { sessionId: string; startTime: string; modifiedTime: string; summary?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }; }; if (!session) { return undefined; } return CopilotClient.toSessionMetadata(session); } private static toSessionMetadata(raw: { sessionId: string; startTime: string; modifiedTime: string; summary?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }): SessionMetadata { const { context } = raw; return { sessionId: raw.sessionId, startTime: new Date(raw.startTime), modifiedTime: new Date(raw.modifiedTime), summary: raw.summary, isRemote: raw.isRemote, context: context ? { workingDirectory: context.cwd, gitRoot: context.gitRoot, repository: context.repository, branch: context.branch, } : undefined, }; } /** * Gets the foreground session ID in TUI+server mode. * * This returns the ID of the session currently displayed in the TUI. * Only available when connecting to a server running in TUI+server mode (--ui-server). * * @returns A promise that resolves with the foreground session ID, or undefined if none * @throws Error if the client is not connected * * @example * ```typescript * const sessionId = await client.getForegroundSessionId(); * if (sessionId) { * console.log(`TUI is displaying session: ${sessionId}`); * } * ``` */ async getForegroundSessionId(): Promise { if (!this.connection) { throw new Error("Client not connected"); } const response = await this.connection.sendRequest("session.getForeground", {}); return (response as ForegroundSessionInfo).sessionId; } /** * Sets the foreground session in TUI+server mode. * * This requests the TUI to switch to displaying the specified session. * Only available when connecting to a server running in TUI+server mode (--ui-server). * * @param sessionId - The ID of the session to display in the TUI * @returns A promise that resolves when the session is switched * @throws Error if the client is not connected or if the operation fails * * @example * ```typescript * // Switch the TUI to display a specific session * await client.setForegroundSessionId("session-123"); * ``` */ async setForegroundSessionId(sessionId: string): Promise { if (!this.connection) { throw new Error("Client not connected"); } const response = await this.connection.sendRequest("session.setForeground", { sessionId }); const result = response as { success: boolean; error?: string }; if (!result.success) { throw new Error(result.error || "Failed to set foreground session"); } } /** * Subscribes to a specific session lifecycle event type. * * Lifecycle events are emitted when sessions are created, deleted, updated, * or change foreground/background state (in TUI+server mode). * * @param eventType - The specific event type to listen for * @param handler - A callback function that receives events of the specified type * @returns A function that, when called, unsubscribes the handler * * @example * ```typescript * // Listen for when a session becomes foreground in TUI * const unsubscribe = client.onLifecycle("session.foreground", (event) => { * console.log(`Session ${event.sessionId} is now displayed in TUI`); * }); * * // Later, to stop receiving events: * unsubscribe(); * ``` */ onLifecycle( eventType: K, handler: TypedSessionLifecycleHandler ): () => void; /** * Subscribes to all session lifecycle events. * * @param handler - A callback function that receives all lifecycle events * @returns A function that, when called, unsubscribes the handler * * @example * ```typescript * const unsubscribe = client.onLifecycle((event) => { * switch (event.type) { * case "session.foreground": * console.log(`Session ${event.sessionId} is now in foreground`); * break; * case "session.created": * console.log(`New session created: ${event.sessionId}`); * break; * } * }); * * // Later, to stop receiving events: * unsubscribe(); * ``` */ onLifecycle(handler: SessionLifecycleHandler): () => void; onLifecycle( eventTypeOrHandler: K | SessionLifecycleHandler, handler?: TypedSessionLifecycleHandler ): () => void { // Overload 1: onLifecycle(eventType, handler) - typed event subscription if (typeof eventTypeOrHandler === "string" && handler) { const eventType = eventTypeOrHandler; if (!this.typedLifecycleHandlers.has(eventType)) { this.typedLifecycleHandlers.set(eventType, new Set()); } const storedHandler = handler as (event: SessionLifecycleEvent) => void; this.typedLifecycleHandlers.get(eventType)!.add(storedHandler); return () => { const handlers = this.typedLifecycleHandlers.get(eventType); if (handlers) { handlers.delete(storedHandler); } }; } // Overload 2: onLifecycle(handler) - wildcard subscription const wildcardHandler = eventTypeOrHandler as SessionLifecycleHandler; this.sessionLifecycleHandlers.add(wildcardHandler); return () => { this.sessionLifecycleHandlers.delete(wildcardHandler); }; } /** * Builds the environment for the spawned runtime child process (stdio/TCP): applies * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry * variables on top of the effective env. Not used by the in-process (FFI) transport, * whose worker inherits the host process's ambient environment * (see {@link CopilotClient.startInProcessFfi}). */ private buildRuntimeEnv(): Record { const env: Record = { ...this.resolvedEnv }; delete env.NODE_DEBUG; if (this.options.gitHubToken) { env.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; } if (this.effectiveConnectionToken) { env.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; } if (this.options.baseDirectory) { env.COPILOT_HOME = this.options.baseDirectory; } // In empty mode, disable the system keychain. Keytar reads from a // process-wide store that's shared across sessions, which is unsafe // for multi-tenant hosts. The runtime falls back to file-based // credential storage scoped to COPILOT_HOME. if (this.options.mode === "empty") { env.COPILOT_DISABLE_KEYTAR = "1"; } if (this.options.mode !== "empty") { env.COPILOT_RUNTIME_PROCESS_FILE_LOGGING = "1"; } if (this.options.telemetry) { const t = this.options.telemetry; env.COPILOT_OTEL_ENABLED = "true"; if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; if (t.captureContent !== undefined) env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String(t.captureContent); } return env; } /** * Start the CLI server process */ private async startCLIServer(): Promise { this.resolvedCliPath ??= await getBundledRuntimePath(); return new Promise((resolve, reject) => { // Clear stderr buffer for fresh capture this.stderrBuffer = ""; const args = [...this.connectionExtraArgs, "--headless", "--no-auto-update"]; if (this.options.logLevel) { args.push("--log-level", this.options.logLevel); } // Choose transport mode based on the resolved connection config. if (this.connectionConfig.kind === "stdio") { args.push("--stdio"); } else if (this.connectionConfig.kind === "tcp") { const requestedPort = this.connectionConfig.port ?? 0; if (requestedPort > 0) { args.push("--port", requestedPort.toString()); } } // Add auth-related flags if (this.options.gitHubToken) { args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"); } if (!this.options.useLoggedInUser) { args.push("--no-auto-login"); } if ( this.options.sessionIdleTimeoutSeconds !== undefined && this.options.sessionIdleTimeoutSeconds > 0 ) { args.push( "--session-idle-timeout", this.options.sessionIdleTimeoutSeconds.toString() ); } if (this.options.enableRemoteSessions) { args.push("--remote"); } // Suppress debug/trace output that might pollute stdout, and apply the // shared runtime env (auth token, connection token, COPILOT_HOME, telemetry). const envWithoutNodeDebug = this.buildRuntimeEnv(); if (!this.resolvedCliPath) { throw new Error( "Path to Copilot CLI is required. Please supply it via " + "`RuntimeConnection.forStdio({ path })` or " + "`RuntimeConnection.forTcp({ path })`, set the COPILOT_CLI_PATH " + "environment variable, or use `RuntimeConnection.forUri(...)` to " + "connect to an already-running runtime." ); } // Verify CLI exists before attempting to spawn if (!existsSync(this.resolvedCliPath)) { throw new Error( `Copilot CLI not found at ${this.resolvedCliPath}. Set COPILOT_CLI_PATH to use a custom installation.` ); } const stdioConfig: ["pipe", "pipe", "pipe"] | ["ignore", "pipe", "pipe"] = this.connectionConfig.kind === "stdio" ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"]; // For .js files, spawn node explicitly; for executables, spawn directly const isJsFile = this.resolvedCliPath.endsWith(".js"); if (isJsFile) { this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { stdio: stdioConfig, cwd: this.options.workingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); } else { this.cliProcess = spawn(this.resolvedCliPath, args, { stdio: stdioConfig, cwd: this.options.workingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); } let stdout = ""; let resolved = false; // For stdio mode, we're ready immediately after spawn if (this.connectionConfig.kind === "stdio") { resolved = true; resolve(); } else { // For TCP mode, wait for port announcement this.cliProcess.stdout?.on("data", (data: Buffer) => { stdout += data.toString(); const match = stdout.match(/listening on port (\d+)/i); if (match && !resolved) { this.runtimePort = parseInt(match[1], 10); resolved = true; resolve(); } }); } this.cliProcess.stderr?.on("data", (data: Buffer) => { // Capture stderr for error messages this.stderrBuffer += data.toString(); // Forward CLI stderr to parent's stderr so debug logs are visible const lines = data.toString().split("\n"); for (const line of lines) { if (line.trim()) { process.stderr.write(`[CLI subprocess] ${line}\n`); } } }); this.cliProcess.on("error", (error) => { if (!resolved) { resolved = true; const stderrOutput = this.stderrBuffer.trim(); if (stderrOutput) { reject( new Error( `Failed to start CLI server: ${error.message}\nstderr: ${stderrOutput}` ) ); } else { reject(new Error(`Failed to start CLI server: ${error.message}`)); } } }); // Set up a promise that rejects when the process exits (used to race against RPC calls) this.processExitPromise = new Promise((_, rejectProcessExit) => { this.cliProcess!.on("exit", (code) => { if (this.messageWriter) { this.messageWriter.suppressWriteErrors = true; } const stderrOutput = this.stderrBuffer.trim(); if (stderrOutput) { rejectProcessExit( new Error( `CLI server exited with code ${code}\nstderr: ${stderrOutput}` ) ); } else { rejectProcessExit( new Error(`CLI server exited unexpectedly with code ${code}`) ); } }); }); // Prevent unhandled rejection when process exits normally (we only use this in Promise.race) this.processExitPromise.catch(() => {}); this.cliProcess.on("close", (code) => { if (!resolved) { resolved = true; const stderrOutput = this.stderrBuffer.trim(); if (stderrOutput) { reject( new Error( `CLI server exited with code ${code}\nstderr: ${stderrOutput}` ) ); } else { reject(new Error(`CLI server exited with code ${code}`)); } } }); // Timeout after 30 seconds (Windows CI runners can be slow to spawn processes) this.cliStartTimeout = setTimeout(() => { if (!resolved) { resolved = true; reject(new Error("Timeout waiting for CLI server to start")); } }, 30000); }); } /** * Connect to the CLI server (via socket or stdio) */ private async connectToServer(): Promise { switch (this.connectionConfig.kind) { case "parent-process": return this.connectToParentProcessViaStdio(); case "stdio": return this.connectToChildProcessViaStdio(); case "inprocess": return this.connectViaFfi(); case "tcp": return this.connectViaTcp(); case "uri": { const { host, port } = this.parseCliUrl(this.connectionConfig.url); this.actualHost = host; this.runtimePort = port; return this.connectViaTcp(); } } } /** Starts the in-process FFI runtime with SDK-managed typed options. */ private async startInProcessFfi(): Promise { const explicitEntrypoint = this.resolvedEnv.COPILOT_CLI_PATH; let runtimeLibrary: string; if (explicitEntrypoint) { const entrypointDirectory = dirname(resolve(explicitEntrypoint)); const adjacentRuntime = join(entrypointDirectory, "runtime.node"); runtimeLibrary = existsSync(adjacentRuntime) ? adjacentRuntime : join( entrypointDirectory, "prebuilds", CopilotClient.getNapiPrebuildsFolder(explicitEntrypoint), "runtime.node" ); } else { runtimeLibrary = join(dirname(await getBundledRuntimePath()), "runtime.node"); } // Load the FFI host lazily so the native `koffi` addon (and its // platform-specific `koffi.node`) is only loaded on the in-process path; // out-of-process (stdio/tcp) consumers never touch the native dependency. // The transpiled output is per-file (not bundled), so this resolves the // sibling module at runtime in both the ESM and CJS builds. const { FfiRuntimeHost } = await import("./ffiRuntimeHost.js"); const environment: Record = {}; if (this.options.gitHubToken) { environment.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; } if (this.options.baseDirectory) { environment.COPILOT_HOME = this.options.baseDirectory; } if (this.options.mode === "empty") { environment.COPILOT_DISABLE_KEYTAR = "1"; } const args: string[] = []; if (this.options.logLevel) { args.push("--log-level", this.options.logLevel); } if (this.options.gitHubToken) { args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"); } if (!this.options.useLoggedInUser) { args.push("--no-auto-login"); } if (this.options.sessionIdleTimeoutSeconds > 0) { args.push("--session-idle-timeout", this.options.sessionIdleTimeoutSeconds.toString()); } if (this.options.enableRemoteSessions) { args.push("--remote"); } const host = FfiRuntimeHost.create(runtimeLibrary, explicitEntrypoint, environment, args); this.ffiHost = host; await host.start(); } /** * Connect to the in-process FFI runtime host over its receive/send streams, * reusing the same `vscode-jsonrpc` framing as the stdio transport. */ private async connectViaFfi(): Promise { if (!this.ffiHost) { throw new Error("In-process FFI runtime host not started"); } this.messageWriter = new TeardownResilientStreamMessageWriter(this.ffiHost.sendStream); this.connection = createMessageConnection( new StreamMessageReader(this.ffiHost.receiveStream), this.messageWriter ); this.attachConnectionHandlers(); this.connection.listen(); } private static getNapiPrebuildsFolder(entrypoint: string): string { const arch = process.arch; if (arch !== "x64" && arch !== "arm64") { throw new Error(`Unsupported architecture '${arch}' for in-process FFI hosting.`); } let platform: string = process.platform; if (platform === "linux" && CopilotClient.isMusl(entrypoint)) { platform = "linuxmusl"; } return `${platform}-${arch}`; } private static isMusl(entrypoint: string): boolean { if (entrypoint.includes(`copilot-linuxmusl-${process.arch}`)) { return true; } if (entrypoint.includes(`copilot-linux-${process.arch}`)) { return false; } const report = process.report?.getReport(); const header = report && "header" in report ? (report.header as { glibcVersionRuntime?: string }) : undefined; return header !== undefined && header.glibcVersionRuntime === undefined; } /** * Connect to child via stdio pipes */ private async connectToChildProcessViaStdio(): Promise { if (!this.cliProcess) { throw new Error("CLI process not started"); } // Keep stdin pipe errors inside the normal JSON-RPC teardown path. // Preserve the failure reason via the gated debug log rather than discarding it. this.cliProcess.stdin?.on("error", (err) => { if (this.forceStopping) { return; } this.state = "error"; const reason = err instanceof Error ? (err.stack ?? err.message) : String(err); const stderrOutput = this.stderrBuffer.trim(); this.processTransportError = new Error( `CLI server connection failed: ${reason}${stderrOutput ? `\nstderr: ${stderrOutput}` : ""}` ); this.logDebug(`stdin pipe error: ${reason}`); try { this.connection?.dispose(); } catch { // The connection may already be closing after the child process exited. } }); // Create JSON-RPC connection over stdin/stdout this.messageWriter = new TeardownResilientStreamMessageWriter(this.cliProcess.stdin!); this.connection = createMessageConnection( new StreamMessageReader(this.cliProcess.stdout!), this.messageWriter ); this.attachConnectionHandlers(); this.connection.listen(); } /** * Connect to parent via stdio pipes */ private async connectToParentProcessViaStdio(): Promise { if (this.cliProcess) { throw new Error("CLI child process was unexpectedly started in parent process mode"); } // Create JSON-RPC connection over stdin/stdout this.messageWriter = new TeardownResilientStreamMessageWriter(process.stdout); this.connection = createMessageConnection( new StreamMessageReader(process.stdin), this.messageWriter ); this.attachConnectionHandlers(); this.connection.listen(); } /** * Connect to the CLI server via TCP socket */ private async connectViaTcp(): Promise { if (!this.runtimePort) { throw new Error("Server port not available"); } return new Promise((resolve, reject) => { this.socket = new Socket(); const connectionTimeout = setTimeout(() => { this.socket?.destroy(); reject(new Error("Timeout connecting to CLI server")); }, 10000); this.socket.connect(this.runtimePort!, this.actualHost, () => { clearTimeout(connectionTimeout); // Create JSON-RPC connection this.messageWriter = new TeardownResilientStreamMessageWriter(this.socket!); this.connection = createMessageConnection( new StreamMessageReader(this.socket!), this.messageWriter ); this.attachConnectionHandlers(); this.connection.listen(); resolve(); }); this.socket.on("error", (error) => { clearTimeout(connectionTimeout); reject(new Error(`Failed to connect to CLI server: ${error.message}`)); }); }); } private attachConnectionHandlers(): void { if (!this.connection) { return; } this.connection.onNotification("session.event", (notification: unknown) => { this.handleSessionEventNotification(notification); }); this.connection.onNotification("session.lifecycle", (notification: unknown) => { this.handleSessionLifecycleNotification(notification); }); this.connection.onRequest( "userInput.request", async (params: { sessionId: string; question: string; choices?: string[]; allowFreeform?: boolean; }): Promise<{ answer: string; wasFreeform: boolean }> => await this.handleUserInputRequest(params) ); this.connection.onRequest( "exitPlanMode.request", async ( params: ExitPlanModeRequest & { sessionId: string } ): Promise => await this.handleExitPlanModeRequest(params) ); this.connection.onRequest( "autoModeSwitch.request", async ( params: AutoModeSwitchRequest & { sessionId: string } ): Promise<{ response: AutoModeSwitchResponse }> => await this.handleAutoModeSwitchRequest(params) ); this.connection.onRequest( "systemMessage.transform", async (params: { sessionId: string; sections: Record; }): Promise<{ sections: Record }> => await this.handleSystemMessageTransform(params) ); // Register client session API handlers. const sessions = this.sessions; registerClientSessionApiHandlers(this.connection, (sessionId) => { const session = sessions.get(sessionId); if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); return session.clientSessionApis; }); // Register client *global* API handlers (e.g. LLM inference) on the // same connection. These methods carry no implicit sessionId dispatch // — the runtime calls into a single handler for the whole connection. registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); if (this.installationConfirmationHandler) { // Registered directly so the review receives the transport's request cancellation. const confirmation = createInstallationConfirmationAdapter( this.connection, this.installationConfirmationHandler ); this.connection.onRequest( "installations.confirm", (params: InstallationConfirmationRequest, token: CancellationToken) => confirmation.confirm(params, token) ); } this.connection.onRequest("host.materializeSession", (params) => this.createHostSession(params) ); this.connection.onNotification( "host.sessionReleased", (params: { hostId: string; handoffId: string }) => { if (this.hostHandoffs.get(params.handoffId)?.hostId === params.hostId) { this.releaseHostSession(params.handoffId); } } ); // `hooks.invoke` is an internal RPC method: the runtime calls it to // invoke a hook callback on the client. Route each call to the matching // session's dispatcher. Not part of the public ClientGlobalApiHandlers // interface because HookInvokeRequest/HookType are internal types. this.connection.onRequest( "hooks.invoke", async (params: { sessionId: string; hookType: string; input: unknown }) => { return await this.handleHooksInvoke(params); } ); const connection = this.connection; const messageWriter = this.messageWriter; const markDisconnected = () => { if (this.connection !== connection) { connection.dispose(); return; } this.connectionClosed = true; this.state = "disconnected"; this.disconnectHosts(); for (const session of this.sessions.values()) { session._markDisconnected(); } this.sessions.clear(); this.githubTokenProviders.clear(); this.requestAdapter?.cancelPending(); connection.dispose(); }; this.connection.onClose(() => { if (messageWriter) messageWriter.suppressWriteErrors = true; // jsonrpc dispatches parsed messages asynchronously, one per event-loop // turn. Drain them before clearing callbacks and rejecting unanswered RPCs. void connection.drain().then(markDisconnected); }); this.connection.onError(() => { if (this.connection === connection) { this.state = "disconnected"; } }); } private handleSessionEventNotification(notification: unknown): void { if ( typeof notification !== "object" || !notification || !("sessionId" in notification) || typeof (notification as { sessionId?: unknown }).sessionId !== "string" || !("event" in notification) ) { return; } const session = this.sessions.get((notification as { sessionId: string }).sessionId); const event = (notification as { event: SessionEvent }).event; if (session) { session._dispatchEvent(event); } } private handleSessionLifecycleNotification(notification: unknown): void { if ( typeof notification !== "object" || !notification || !("type" in notification) || typeof (notification as { type?: unknown }).type !== "string" || !("sessionId" in notification) || typeof (notification as { sessionId?: unknown }).sessionId !== "string" ) { return; } const raw = notification as { type: SessionLifecycleEventType; sessionId: string; metadata?: { startTime?: string; modifiedTime?: string; summary?: string }; }; let metadata: SessionLifecycleEvent["metadata"]; if (raw.metadata && raw.metadata.startTime && raw.metadata.modifiedTime) { metadata = { startTime: new Date(raw.metadata.startTime), modifiedTime: new Date(raw.metadata.modifiedTime), summary: raw.metadata.summary, }; } const event = { type: raw.type, sessionId: raw.sessionId, metadata, } as SessionLifecycleEvent; // Dispatch to typed handlers for this specific event type const typedHandlers = this.typedLifecycleHandlers.get(event.type); if (typedHandlers) { for (const handler of typedHandlers) { try { handler(event); } catch { // Ignore handler errors } } } // Dispatch to wildcard handlers for (const handler of this.sessionLifecycleHandlers) { try { handler(event); } catch { // Ignore handler errors } } } private async handleUserInputRequest(params: { sessionId: string; question: string; choices?: string[]; allowFreeform?: boolean; }): Promise<{ answer: string; wasFreeform: boolean }> { if ( !params || typeof params.sessionId !== "string" || typeof params.question !== "string" ) { throw new Error("Invalid user input request payload"); } const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } const result = await session._handleUserInputRequest({ question: params.question, choices: params.choices, allowFreeform: params.allowFreeform, }); return result; } private async handleExitPlanModeRequest( params: ExitPlanModeRequest & { sessionId: string } ): Promise { if ( !params || typeof params.sessionId !== "string" || typeof params.summary !== "string" || !Array.isArray(params.actions) || typeof params.recommendedAction !== "string" ) { throw new Error("Invalid exit plan mode request payload"); } const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return await session._handleExitPlanModeRequest({ summary: params.summary, planContent: params.planContent, actions: params.actions, recommendedAction: params.recommendedAction, }); } private async handleAutoModeSwitchRequest( params: AutoModeSwitchRequest & { sessionId: string } ): Promise<{ response: AutoModeSwitchResponse }> { if (!params || typeof params.sessionId !== "string") { throw new Error("Invalid auto mode switch request payload"); } const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } const response = await session._handleAutoModeSwitchRequest({ errorCode: params.errorCode, retryAfterSeconds: params.retryAfterSeconds, }); return { response }; } private async handleHooksInvoke(params: { sessionId: string; hookType: string; input: unknown; }): Promise<{ output?: unknown }> { if ( !params || typeof params.sessionId !== "string" || typeof params.hookType !== "string" ) { throw new Error("Invalid hooks invoke payload"); } const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } const output = await session._handleHooksInvoke(params.hookType, params.input); return { output }; } private async handleSystemMessageTransform(params: { sessionId: string; sections: Record; }): Promise<{ sections: Record }> { if ( !params || typeof params.sessionId !== "string" || !params.sections || typeof params.sections !== "object" ) { throw new Error("Invalid systemMessage.transform payload"); } const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return await session._handleSystemMessageTransform(params.sections); } }