/*---------------------------------------------------------------------------------------------
 *  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,
    type AhpSessionCreateRequest,
} 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,
    TranscriptRecovery,
    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;
const CLOUD_SESSION_CLEANUP_TIMEOUT_MS = 10_000;

type DrainingMessageConnection = MessageConnection & { drain: () => Promise<void> };

function createMessageConnection(
    reader: StreamMessageReader,
    writer: StreamMessageWriter
): DrainingMessageConnection {
    let dispatch: DataCallback | undefined;
    let finishDrain: (() => void) | undefined;
    let drained: Promise<void> | 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<void>((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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
    let timeout: ReturnType<typeof setTimeout> | undefined;
    try {
        return await Promise.race([
            promise,
            new Promise<never>((_, reject) => {
                timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
            }),
        ]);
    } finally {
        if (timeout !== undefined) {
            clearTimeout(timeout);
        }
    }
}

async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
    if (child.exitCode != null || child.signalCode != null) {
        return true;
    }

    return new Promise<boolean>((resolve) => {
        let timeout: ReturnType<typeof setTimeout>;
        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<ProviderConfig, "bearerTokenProvider"> & {
    hasBearerTokenProvider?: boolean;
};

/** Wire-safe named provider config carrying the `hasBearerTokenProvider` flag. */
type WireNamedProviderConfig = Omit<NamedProviderConfig, "bearerTokenProvider"> & {
    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<string, BearerTokenProvider>;
} {
    const callbacks = new Map<string, BearerTokenProvider>();

    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<string, MCPServerConfig> | undefined
): Record<string, unknown> | 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<string, unknown> | undefined {
    if (!config) return undefined;
    const { outputDirectory, ...rest } = config;
    const wire: Record<string, unknown> = { ...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<string, SectionTransformFn> | undefined;
} {
    if (!systemMessage || systemMessage.mode !== "customize" || !systemMessage.sections) {
        return { wirePayload: systemMessage, transformCallbacks: undefined };
    }

    const transformCallbacks = new Map<string, SectionTransformFn>();
    const wireSections: Record<string, { action: string; content?: string }> = {};

    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<string> {
    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()`, transport EOF, or a stdin pipe error, 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<void> {
        try {
            await super.write(msg);
        } catch (error) {
            if (!this.suppressWriteErrors) {
                throw error;
            }
        }
    }
}

export class CopilotClient {
    private cliStartTimeout: ReturnType<typeof setTimeout> | null = null;
    private cliProcess: ChildProcess | null = null;
    private ffiHost: FfiRuntimeHost | null = null;
    private connection: DrainingMessageConnection | null = null;
    private requestAdapter: ReturnType<typeof createCopilotRequestAdapter> | null = null;
    private messageWriter: TeardownResilientStreamMessageWriter | null = null;
    private connectionClosed: boolean = false;
    private connectionEpoch = 0;
    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<void> | null = null;
    private sessions: Map<string, CopilotSession> = new Map();
    private hostExitCallbacks = new Map<string, (exit: AhpHostExit) => void>();
    private hostSessionFactories = new Map<
        string,
        Pick<AhpHostOptions, "createSession" | "resumeSession" | "onSessionReleased"> & {
            policy: { mode: "github" | "none" | "custom"; requireIdentity?: string };
        }
    >();
    private hostAuthenticators = new Map<
        string,
        Extract<AhpHostOptions["authentication"], { mode: "custom" }>
    >();
    private hostAuthentications = new Map<
        string,
        { hostId: string; controller: AbortController }
    >();
    private hostHandoffs = new Map<
        string,
        {
            hostId: string;
            requestedSessionId?: string;
            controller: AbortController;
            configs: Map<CopilotSession, SessionConfig>;
            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<string, string | undefined>;
    private options: {
        entra?: CopilotClientOptions["entra"];
        workingDirectory: string;
        logLevel?: string;
        gitHubToken?: string;
        useLoggedInUser: boolean;
        telemetry?: TelemetryConfig;
        baseDirectory?: string;
        sessionIdleTimeoutSeconds: number;
        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[]> | ModelInfo[];
    private onGetTraceContext?: TraceContextProvider;
    private modelsCache: ModelInfo[] | null = null;
    private modelsCacheLock: Promise<void> = Promise.resolve();
    private sessionLifecycleHandlers: Set<SessionLifecycleHandler> = new Set();
    private typedLifecycleHandlers: Map<
        SessionLifecycleEventType,
        Set<(event: SessionLifecycleEvent) => void>
    > = new Map();
    private _rpc: ReturnType<typeof createServerRpc> | null = null;
    private _internalRpc: ReturnType<typeof createInternalServerRpc> | null = null;
    private processExitPromise: Promise<never> | 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<void>;
    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<typeof createServerRpc> {
        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<typeof createInternalServerRpc> {
        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<string, string> | 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 = {
            entra: options.entra ? { ...options.entra } : undefined,
            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,
            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."
            );
        }
        if (
            this.sessionFsConfig.capabilities?.binary &&
            (!provider.readFileBytes || !provider.writeFileBytes)
        ) {
            throw new Error(
                "SessionFsConfig declares capabilities.binary but the provider does not implement readFileBytes and writeFileBytes."
            );
        }
        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),
        };
        handlers.uiThread = {
            run: async ({ actionId }) => {
                const connection = this.connectionConfig;
                const host = this.ffiHost;
                if (connection.kind !== "inprocess" || !connection.runOnUiThread || !host) {
                    throw new Error("No in-process runOnUiThread handler is configured.");
                }
                await connection.runOnUiThread(() => host.runUiAction(actionId));
            },
        };
        this.clientGlobalHandlers = handlers;
    }

    private async acquireGitHubToken(
        params: GitHubTokenAcquireRequest
    ): Promise<GitHubTokenAcquireResult> {
        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<void> {
        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<void> {
        this.forceStopping = false;
        this.connectionEpoch++;
        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", {});
            }

            if (this.connectionClosed) {
                throw new Error("CLI server connection closed during startup");
            }
            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<Error[]> {
        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++) {
                // Closure retires sessions after notification draining, so this
                // snapshot may outlive the connection that could disconnect it.
                if (this.connectionClosed) {
                    lastError = null;
                    break;
                }
                try {
                    await session.disconnect();
                    lastError = null;
                    break; // Success
                } catch (error) {
                    if (this.connectionClosed) {
                        lastError = null;
                        break;
                    }
                    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) {
                    let timeout: ReturnType<typeof setTimeout> | undefined;
                    try {
                        await new Promise<void>((resolve) => {
                            socket.once("close", () => resolve());
                            if (this.connectionClosed) {
                                // An exited child may leave a TCP peer that never finishes closing.
                                socket.destroy();
                            } else {
                                timeout = setTimeout(
                                    () => socket.destroy(),
                                    RUNTIME_SHUTDOWN_TIMEOUT_MS
                                );
                                socket.end();
                            }
                        });
                    } finally {
                        if (timeout !== undefined) clearTimeout(timeout);
                    }
                }
            } 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<void> {
        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)
     * - Waits for the spawned CLI process to exit
     *
     * @returns A promise that resolves when the force stop is complete
     * @throws If the spawned CLI process cannot be terminated
     *
     * @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<void> {
        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;
        }

        // 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;
        if (this.cliProcess && !this.isExternalServer) {
            const child = this.cliProcess;
            if (child.exitCode == null && child.signalCode == null) {
                child.kill("SIGKILL");
                // SIGKILL requests termination; Windows file mappings can outlive that request.
                if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) {
                    throw new Error(
                        `Timed out waiting for CLI process to exit after SIGKILL: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms`
                    );
                }
            }
            if (this.cliProcess === child) {
                this.cliProcess = 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<SessionConfigBase> {
        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<void> {
        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<CopilotSession> {
        if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) {
            throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive");
        }
        if (config.cloud != null && config.skillProvider) {
            throw new Error("Skill providers are not supported for cloud sessions.");
        }
        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 (config.skillProvider) {
                s.registerSkillProvider(config.skillProvider);
            }
            if (transformCallbacks) {
                s.registerTransformCallbacks(transformCallbacks);
            }
            if (config.onEvent) {
                s.on(config.onEvent);
            }
            // Session-FS setup can throw, so finish it before the session
            // becomes routable; a failed open must not leave handlers behind.
            this.setupSessionFs(s, config);
            this.sessions.set(sessionId, s);
            return s;
        };

        let session: CopilotSession | undefined;
        let registeredId: string | undefined;
        let uninitializedCloudSessionId: 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,
                name: config.name,
                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,
                    namespace: tool.namespace,
                })),
                toolSearch: config.toolSearch,
                imageGeneration: config.imageGeneration,
                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,
                autopilot: config.autopilot,
                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,
                trustWorkingDirectory: config.trustWorkingDirectory,
                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,
                ...(config.skillProvider ? { hasSkillProvider: true } : {}),
                agentDirectories: config.agentDirectories,
                hookDirectories: config.hookDirectories,
                pluginDirectories: config.pluginDirectories,
                instructionDirectories: config.instructionDirectories,
                disabledSkills: config.disabledSkills,
                disabledMcpServers: config.disabledMcpServers,
                sandboxConfig: config.sandboxConfig,
                infiniteSessions: config.infiniteSessions,
                memory: config.memory,
                gitHubToken: config.gitHubToken,
                gitHubTokenProviderRegistrationId,
                cloud: config.cloud,
                featureFlags: config.featureFlags,
                expAssignments: config.expAssignments,
                enableManagedSettings: config.enableManagedSettings,
                excludedManagedSettings: config.excludedManagedSettings,
                enforceManagedModelDefaults: config.enforceManagedModelDefaults,
                interactiveUser: config.interactiveUser,
                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.
                uninitializedCloudSessionId = returnedSessionId;
                session = initializeSession(returnedSessionId);
                uninitializedCloudSessionId = undefined;
                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);
            }
            if (uninitializedCloudSessionId !== undefined) {
                try {
                    await withTimeout(
                        this.deleteSession(uninitializedCloudSessionId),
                        CLOUD_SESSION_CLEANUP_TIMEOUT_MS,
                        `session.delete timed out after ${CLOUD_SESSION_CLEANUP_TIMEOUT_MS}ms`
                    );
                } catch (cleanupError) {
                    throw new AggregateError(
                        [e, cleanupError],
                        "Failed to initialize and delete cloud session"
                    );
                }
            }
            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<CopilotSession> {
        return this.resumeSessionInternal(sessionId, config);
    }

    /** @internal */
    async resumeSessionForExtension(
        sessionId: string,
        config: ResumeSessionConfig,
        contributions: ExtensionOrchestrationContributions = {},
        extensionOptions?: ExtensionJoinOptions
    ): Promise<CopilotSession> {
        return this.resumeSessionInternal(sessionId, config, contributions, extensionOptions);
    }

    private async resumeSessionInternal(
        sessionId: string,
        config: ResumeSessionConfig,
        contributions: ExtensionOrchestrationContributions = {},
        extensionOptions?: ExtensionJoinOptions
    ): Promise<CopilotSession> {
        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);
        }
        if (config.skillProvider) {
            session.registerSkillProvider(config.skillProvider);
        }

        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);
        }
        // Validation and session-FS setup can throw, so finish them before the
        // replacement session becomes routable over any resident one.
        const toolFilterOptions = this.resolveToolFilterOptions(config);
        this.setupSessionFs(session, config);
        const replacedSession = this.sessions.get(sessionId);
        this.sessions.set(sessionId, session);

        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,
                allowTranscriptRecovery: config.allowTranscriptRecovery,
                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,
                autopilot: config.autopilot,
                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,
                    namespace: tool.namespace,
                })),
                toolSearch: config.toolSearch,
                imageGeneration: config.imageGeneration,
                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,
                ...(config.skillProvider ? { hasSkillProvider: true } : {}),
                agentDirectories: config.agentDirectories,
                hookDirectories: config.hookDirectories,
                pluginDirectories: config.pluginDirectories,
                instructionDirectories: config.instructionDirectories,
                disabledSkills: config.disabledSkills,
                disabledMcpServers: config.disabledMcpServers,
                sandboxConfig: config.sandboxConfig,
                infiniteSessions: config.infiniteSessions,
                memory: config.memory,
                disableResume: config.suppressResumeEvent,
                continuePendingWork: config.continuePendingWork,
                gitHubToken: config.gitHubToken,
                gitHubTokenProviderRegistrationId,
                openCanvases: config.openCanvases,
                featureFlags: config.featureFlags,
                expAssignments: config.expAssignments,
                enableManagedSettings: config.enableManagedSettings,
                excludedManagedSettings: config.excludedManagedSettings,
                enforceManagedModelDefaults: config.enforceManagedModelDefaults,
                interactiveUser: config.interactiveUser,
                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<string, string>;
                };
                for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) {
                    if (requested.has(name)) {
                        process.env[name] = value;
                    }
                }
            }

            const { workspacePath, capabilities, openCanvases, transcriptRecovery } = response as {
                sessionId: string;
                workspacePath?: string;
                capabilities?: SessionCapabilities;
                openCanvases?: OpenCanvasInstance[];
                transcriptRecovery?: TranscriptRecovery;
            };
            session["_workspacePath"] = workspacePath;
            session.setCapabilities(capabilities);
            session.setOpenCanvases(openCanvases ?? []);
            session.setTranscriptRecovery(transcriptRecovery);
            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();
            // The runtime keeps the resident session's bindings when a resume
            // fails, so its callbacks must keep reaching the replaced session.
            if (this.sessions.get(sessionId) === session) {
                if (replacedSession) {
                    this.sessions.set(sessionId, replacedSession);
                } else {
                    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 a compute-scoped 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<AhpHost> {
        const authentication = options?.authentication;
        if (!authentication || !["github", "none", "custom"].includes(authentication.mode)) {
            throw new Error("An explicit authentication mode is required");
        }
        if (
            authentication.mode === "custom" &&
            (typeof authentication.requireIdentity !== "string" ||
                !authentication.requireIdentity.trim() ||
                typeof authentication.authenticate !== "function")
        ) {
            throw new Error(
                "Custom authentication requires a nonempty requireIdentity and authenticate callback"
            );
        }
        if (authentication.mode !== "github" && options.githubEnvironment) {
            throw new Error("githubEnvironment requires github authentication");
        }
        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();
        if (authentication.mode === "custom") {
            this.hostAuthenticators.set(hostId, { ...authentication });
        }
        const {
            computeId,
            workspace,
            allowCreatingNewSessions,
            localServer,
            githubEnvironment,
            onExit,
            createSession,
            resumeSession,
            onSessionReleased,
        } = options;
        if (createSession || resumeSession) {
            this.hostSessionFactories.set(hostId, {
                createSession,
                resumeSession,
                onSessionReleased,
                policy: {
                    mode: authentication.mode,
                    requireIdentity:
                        authentication.mode === "custom"
                            ? authentication.requireIdentity
                            : undefined,
                },
            });
        }
        if (onExit) {
            this.hostExitCallbacks.set(hostId, onExit);
        }
        try {
            const info = await rpc.host.start({
                hostId,
                authentication:
                    authentication.mode === "custom"
                        ? { mode: "custom", requireIdentity: authentication.requireIdentity }
                        : { mode: authentication.mode },
                computeId,
                workspace,
                localServer,
                githubEnvironment,
                sessionFactory: createSession ? true : undefined,
                resumeFactory: resumeSession ? true : undefined,
                allowCreatingNewSessions,
            });
            return new AhpHost(
                info,
                async () => {
                    await rpc.host.dispose({ hostId });
                },
                (sessionId) => rpc.host.publishSession({ hostId, sessionId }),
                () => rpc.host.listSessions({ hostId }),
                (sessionId) => rpc.host.unpublishSession({ hostId, sessionId }),
                async (allowed) => {
                    await rpc.host.setSessionCreationPolicy({
                        hostId,
                        allowCreatingNewSessions: allowed,
                    });
                }
            );
        } 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.hostAuthenticators.keys(),
        ])) {
            this.handleHostExit({
                hostId,
                reason: "ownerDisconnected",
                error: "Owner connection closed; runtime cleanup cannot be acknowledged on this connection.",
            });
        }
    }

    private releaseHostSessions(hostId: string): void {
        this.hostAuthenticators.delete(hostId);
        for (const [requestId, entry] of this.hostAuthentications) {
            if (entry.hostId === hostId) {
                this.hostAuthentications.delete(requestId);
                entry.controller.abort();
            }
        }
        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;
        authentication?: AhpSessionCreateRequest["authentication"];
        config: Omit<SessionConfig, "onPermissionRequest"> & { configDir?: string };
    }): Promise<{ sessionId: string }> {
        const authentication = params.authentication;
        if (!authentication) {
            throw new Error("AHP session handoff authentication is required");
        }
        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 policy = options!.policy;
        const identity = (authentication as { identity?: unknown }).identity;
        if (
            authentication.mode !== policy.mode ||
            (authentication.mode === "none"
                ? identity !== undefined
                : typeof identity !== "string" || !identity.trim()) ||
            (policy.mode === "custom" && identity !== policy.requireIdentity)
        ) {
            throw new Error("AHP trusted authentication does not match host policy");
        }
        const entry: {
            hostId: string;
            requestedSessionId?: string;
            controller: AbortController;
            configs: Map<CopilotSession, SessionConfig>;
            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<never>((_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!({
                        authentication,
                        sessionId,
                        config: resumeConfig,
                        signal: entry.controller.signal,
                    });
                }
                return options!.createSession!({
                    authentication,
                    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<string, unknown>)[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<GetStatusResponse> {
        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<GetAuthStatusResponse> {
        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<ModelInfo[]> {
        // 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<void> {
        if (!this.connection) {
            throw new Error("Client not connected");
        }
        const maxVersion = getSdkProtocolVersion();
        const raceAgainstExit = <T>(p: Promise<T>): Promise<T> =>
            this.processExitPromise ? Promise.race([p, this.processExitPromise]) : p;

        let serverVersion: number | undefined;
        try {
            const connectParams: {
                entra?: CopilotClientOptions["entra"];
                token?: string;
                enableGitHubTelemetryForwarding?: boolean;
                clientInfo?: ConnectClientInfo;
                supportedTaskKinds?: TaskKind[];
            } = {
                entra: this.options.entra,
                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.
                if (this.options.entra) {
                    throw new Error("This runtime does not support Entra client configuration.");
                }
                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<string | undefined> {
        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<void> {
        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<SessionMetadata[]> {
        if (!this.connection) {
            throw new Error("Client not connected");
        }

        // Transform filter to wire format (workingDirectory → cwd)
        let wireFilter: Record<string, unknown> | 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<SessionMetadata | undefined> {
        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<string | undefined> {
        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<void> {
        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<K extends SessionLifecycleEventType>(
        eventType: K,
        handler: TypedSessionLifecycleHandler<K>
    ): () => 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<K extends SessionLifecycleEventType>(
        eventTypeOrHandler: K | SessionLifecycleHandler,
        handler?: TypedSessionLifecycleHandler<K>
    ): () => 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<string, string | undefined> {
        const env: Record<string, string | undefined> = { ...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<void> {
        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()
                );
            }

            // 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<never>((_, 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" +
                                `\nstdout: ${stdout.trim().slice(-8192) || "(empty)"}` +
                                `\nstderr: ${this.stderrBuffer.trim().slice(-8192) || "(empty)"}`
                        )
                    );
                }
            }, 30000);
        });
    }

    /**
     * Connect to the CLI server (via socket or stdio)
     */
    private async connectToServer(): Promise<void> {
        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<void> {
        const explicitEntrypoint = this.resolvedEnv.COPILOT_CLI_PATH;
        const cliLoginSyncIntegrationId =
            this.resolvedEnv.GITHUB_COPILOT_INTEGRATION_ID?.trim() || undefined;
        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 {
            const bundledRuntimePath = await getBundledRuntimePath();
            runtimeLibrary = join(dirname(bundledRuntimePath), "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<string, string> = {};
        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 (cliLoginSyncIntegrationId) {
            args.push(`--cli-login-sync-integration-id=${cliLoginSyncIntegrationId}`);
        }

        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<void> {
        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();
        this.ffiHost.configureUiThread(
            this.connectionConfig.kind === "inprocess" &&
                this.connectionConfig.runOnUiThread !== undefined
        );
    }

    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<void> {
        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}`);
            if (this.messageWriter) {
                this.messageWriter.suppressWriteErrors = true;
            }
            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<void> {
        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<void> {
        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<ExitPlanModeResult> => 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<string, { content: string }>;
            }): Promise<{ sections: Record<string, { content: string }> }> =>
                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.onRequest(
            "host.validateAuthentication",
            async (params: { hostId: string; requestId: string; token: string }) => {
                const authenticator = this.hostAuthenticators.get(params.hostId);
                if (!authenticator || this.hostAuthentications.has(params.requestId)) {
                    throw new Error("AHP authenticator is unavailable or request already exists");
                }
                const entry = { hostId: params.hostId, controller: new AbortController() };
                this.hostAuthentications.set(params.requestId, entry);
                const signal = entry.controller.signal;
                const cancelled = new Promise<never>((_resolve, reject) => {
                    signal.addEventListener(
                        "abort",
                        () => reject(new Error("AHP authentication ended")),
                        { once: true }
                    );
                });
                try {
                    const result = await Promise.race([
                        Promise.resolve().then(() =>
                            authenticator.authenticate({ token: params.token, signal })
                        ),
                        cancelled,
                    ]);
                    if (
                        signal.aborted ||
                        this.hostAuthenticators.get(params.hostId) !== authenticator
                    ) {
                        throw new Error("AHP authentication ended");
                    }
                    return result;
                } finally {
                    if (this.hostAuthentications.get(params.requestId) === entry) {
                        this.hostAuthentications.delete(params.requestId);
                    }
                    entry.controller.abort();
                }
            }
        );
        this.connection.onNotification(
            "host.authenticationCancelled",
            (params: { hostId: string; requestId: string }) => {
                const entry = this.hostAuthentications.get(params.requestId);
                if (entry?.hostId === params.hostId) entry.controller.abort();
            }
        );
        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);
            }
        );

        // Internal experimental callbacks backing `SessionConfigBase.skillProvider`.
        this.connection.onRequest(
            "skillProvider.list",
            async (params: { sessionId: string }, token: CancellationToken) =>
                await this.resolveSkillProviderSession(params)._handleSkillProviderList(token)
        );
        this.connection.onRequest(
            "skillProvider.read",
            async (params: { sessionId: string; name: string }, token: CancellationToken) => {
                const session = this.resolveSkillProviderSession(params);
                if (typeof params.name !== "string") {
                    throw new Error("Invalid skillProvider.read payload");
                }
                return await session._handleSkillProviderRead(params.name, token);
            }
        );

        const connection = this.connection;
        const messageWriter = this.messageWriter;
        const cliProcess = this.isExternalServer ? null : this.cliProcess;
        const connectionEpoch = this.connectionEpoch;
        const isCurrentConnection = () =>
            connectionEpoch === this.connectionEpoch &&
            this.connection === connection &&
            (cliProcess === null || this.cliProcess === cliProcess);
        let disconnecting = false;
        let exitFallback: ReturnType<typeof setTimeout> | undefined;
        const markDisconnected = () => {
            if (!isCurrentConnection()) {
                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.socket?.destroy();
            this.connection = null;
            this.messageWriter = null;
            this._rpc = null;
            this._internalRpc = null;
            this.modelsCache = null;
        };
        const disconnectAfterDrain = () => {
            if (exitFallback !== undefined) {
                clearTimeout(exitFallback);
                exitFallback = undefined;
            }
            if (!isCurrentConnection()) {
                connection.dispose();
                return;
            }
            if (disconnecting) return;
            disconnecting = true;
            this.connectionClosed = true;
            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.onClose(disconnectAfterDrain);
        // Descendants can retain inherited output pipes after the runtime exits.
        // Observe the owned process without waiting for those pipes to reach EOF.
        if (cliProcess) {
            cliProcess.once("exit", () => {
                if (!isCurrentConnection()) {
                    connection.dispose();
                    return;
                }
                if (disconnecting) return;
                if (this.socket) {
                    disconnectAfterDrain();
                    return;
                }
                this.connectionClosed = true;
                if (messageWriter) messageWriter.suppressWriteErrors = true;
                // Reader EOF is authoritative for messages still buffered after process exit.
                // A descendant can keep stdout open, so bound the fallback.
                exitFallback = setTimeout(disconnectAfterDrain, RUNTIME_SHUTDOWN_TIMEOUT_MS);
                // Browser-style hosts such as Electron renderers return numeric timer IDs.
                exitFallback.unref?.();
            });
        }
        this.connection.onError(() => {
            if (connectionEpoch === this.connectionEpoch && 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<ExitPlanModeResult> {
        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 resolveSkillProviderSession(params: { sessionId: string }): CopilotSession {
        if (!params || typeof params.sessionId !== "string") {
            throw new Error("Invalid skillProvider payload");
        }
        const session = this.sessions.get(params.sessionId);
        if (!session) {
            throw new Error(`Session not found: ${params.sessionId}`);
        }
        return session;
    }

    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<string, { content: string }>;
    }): Promise<{ sections: Record<string, { content: string }> }> {
        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);
    }
}