@github/copilot-sdk 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { isIPv6, Socket } from "node:net";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import {
7
- createMessageConnection,
7
+ createMessageConnection as createRpcMessageConnection,
8
8
  ErrorCodes,
9
9
  ResponseError,
10
10
  StreamMessageReader,
@@ -18,6 +18,7 @@ import {
18
18
  } from "./generated/rpc.js";
19
19
  import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
20
20
  import { CopilotSession } from "./session.js";
21
+ import { AhpHost } from "./host.js";
21
22
  import { ensureRuntimeBundle } from "./runtimeArtifacts.js";
22
23
  import { COPILOT_CLI_VERSION } from "./cliVersion.js";
23
24
  import { createSessionFsAdapter } from "./sessionFsProvider.js";
@@ -31,6 +32,57 @@ import { ToolSet } from "./toolSet.js";
31
32
  import { defaultJoinSessionPermissionHandler } from "./types.js";
32
33
  const MIN_PROTOCOL_VERSION = 3;
33
34
  const RUNTIME_SHUTDOWN_TIMEOUT_MS = 1e4;
35
+ function createMessageConnection(reader, writer) {
36
+ let dispatch;
37
+ let finishDrain;
38
+ let drained;
39
+ let disposed = false;
40
+ const barrier = { jsonrpc: "2.0", method: "$/sdkDrain" };
41
+ const connection = createRpcMessageConnection(
42
+ {
43
+ onError: reader.onError,
44
+ onClose: reader.onClose,
45
+ onPartialMessage: reader.onPartialMessage,
46
+ listen: (callback) => {
47
+ dispatch = callback;
48
+ return reader.listen(callback);
49
+ },
50
+ dispose: () => reader.dispose()
51
+ },
52
+ writer,
53
+ void 0,
54
+ {
55
+ messageStrategy: {
56
+ handleMessage: (message, next) => {
57
+ if (message === barrier) {
58
+ finishDrain?.();
59
+ } else {
60
+ next(message);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ );
66
+ connection.onDispose(() => {
67
+ disposed = true;
68
+ finishDrain?.();
69
+ });
70
+ return Object.assign(connection, {
71
+ drain: () => {
72
+ if (!drained) {
73
+ drained = new Promise((resolve2) => {
74
+ finishDrain = resolve2;
75
+ if (disposed || !dispatch) {
76
+ resolve2();
77
+ } else {
78
+ dispatch(barrier);
79
+ }
80
+ });
81
+ }
82
+ return drained;
83
+ }
84
+ });
85
+ }
34
86
  async function withTimeout(promise, timeoutMs, message) {
35
87
  let timeout;
36
88
  try {
@@ -217,6 +269,9 @@ class CopilotClient {
217
269
  /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */
218
270
  startPromise = null;
219
271
  sessions = /* @__PURE__ */ new Map();
272
+ hostExitCallbacks = /* @__PURE__ */ new Map();
273
+ hostSessionFactories = /* @__PURE__ */ new Map();
274
+ hostHandoffs = /* @__PURE__ */ new Map();
220
275
  stderrBuffer = "";
221
276
  // Captures CLI stderr for error messages
222
277
  /** Resolved connection mode chosen in the constructor. */
@@ -508,6 +563,11 @@ class CopilotClient {
508
563
  }
509
564
  setupClientGlobalHandlers() {
510
565
  const handlers = {};
566
+ handlers.host = {
567
+ exited: async (exit) => {
568
+ this.handleHostExit(exit);
569
+ }
570
+ };
511
571
  handlers.extensionLaunchProvider = this.extensionLaunchProvider;
512
572
  if (this.requestHandler) {
513
573
  this.requestAdapter = createCopilotRequestAdapter(this.requestHandler, () => {
@@ -661,7 +721,8 @@ class CopilotClient {
661
721
  * 1. Closes all active sessions (releases in-memory resources)
662
722
  * 2. Requests runtime shutdown for SDK-owned CLI processes
663
723
  * 3. Closes the JSON-RPC connection
664
- * 4. Terminates the CLI server process (if spawned by this client)
724
+ * 4. Signals EOF to an owned stdio process and waits for host cleanup, then
725
+ * terminates the process if it does not exit within the shutdown timeout
665
726
  *
666
727
  * Note: session data on disk is preserved, so sessions can be resumed later.
667
728
  * To permanently remove session data before stopping, call
@@ -779,7 +840,23 @@ class CopilotClient {
779
840
  }
780
841
  if (this.cliProcess && !this.isExternalServer) {
781
842
  const child = this.cliProcess;
782
- this.cliProcess = null;
843
+ if (this.connectionConfig.kind === "stdio" && child.stdin && child.exitCode == null && child.signalCode == null) {
844
+ const gracefulExitStart = Date.now();
845
+ try {
846
+ child.stdin.end();
847
+ const exited = await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS);
848
+ this.logDebugTiming(
849
+ exited ? "CopilotClient.stop graceful stdio exit complete" : "CopilotClient.stop graceful stdio exit timed out; terminating child",
850
+ gracefulExitStart
851
+ );
852
+ } catch (error) {
853
+ errors.push(
854
+ new Error(
855
+ `Failed to close CLI stdin: ${error instanceof Error ? error.message : String(error)}`
856
+ )
857
+ );
858
+ }
859
+ }
783
860
  try {
784
861
  if (child.exitCode == null && child.signalCode == null) {
785
862
  child.kill();
@@ -797,6 +874,10 @@ class CopilotClient {
797
874
  `Failed to kill CLI process: ${error instanceof Error ? error.message : String(error)}`
798
875
  )
799
876
  );
877
+ } finally {
878
+ if (this.cliProcess === child) {
879
+ this.cliProcess = null;
880
+ }
800
881
  }
801
882
  }
802
883
  if (this.ffiHost) {
@@ -820,6 +901,7 @@ class CopilotClient {
820
901
  this.runtimePort = null;
821
902
  this.stderrBuffer = "";
822
903
  this.processExitPromise = null;
904
+ this.disconnectHosts();
823
905
  return errors;
824
906
  }
825
907
  /**
@@ -864,6 +946,7 @@ class CopilotClient {
864
946
  */
865
947
  async forceStop() {
866
948
  this.forceStopping = true;
949
+ this.disconnectHosts();
867
950
  for (const session of this.sessions.values()) {
868
951
  session._markDisconnected();
869
952
  }
@@ -1163,6 +1246,7 @@ class CopilotClient {
1163
1246
  const response = await this.connection.sendRequest("session.create", {
1164
1247
  ...await getTraceContext(this.onGetTraceContext),
1165
1248
  model: config.model,
1249
+ allowedModels: config.allowedModels,
1166
1250
  sessionId: localSessionId,
1167
1251
  clientName: config.clientName,
1168
1252
  reasoningEffort: config.reasoningEffort,
@@ -1293,6 +1377,11 @@ class CopilotClient {
1293
1377
  }
1294
1378
  throw e;
1295
1379
  }
1380
+ for (const entry of this.hostHandoffs.values()) {
1381
+ if (entry.requestedSessionId === session.sessionId) {
1382
+ entry.configs.set(session, config);
1383
+ }
1384
+ }
1296
1385
  return session;
1297
1386
  }
1298
1387
  /**
@@ -1399,6 +1488,7 @@ class CopilotClient {
1399
1488
  }
1400
1489
  try {
1401
1490
  const response = await this.connection.sendRequest("session.resume", {
1491
+ allowedModels: config.allowedModels,
1402
1492
  ...await getTraceContext(this.onGetTraceContext),
1403
1493
  sessionId,
1404
1494
  clientName: config.clientName,
@@ -1527,8 +1617,205 @@ class CopilotClient {
1527
1617
  }
1528
1618
  throw e;
1529
1619
  }
1620
+ for (const entry of this.hostHandoffs.values()) {
1621
+ if (entry.requestedSessionId === session.sessionId) {
1622
+ entry.configs.set(session, { ...config, sessionId });
1623
+ }
1624
+ }
1530
1625
  return session;
1531
1626
  }
1627
+ /**
1628
+ * Start a complete AHP listener hosted in-process by this runtime.
1629
+ *
1630
+ * This connection owns the host. Disposing it or disconnecting the client
1631
+ * stops the listener without deleting sessions. The host uses a separate
1632
+ * SDK connection to the same runtime, not another runtime process.
1633
+ * Only one AHP host may own the catalog in an effective Copilot home
1634
+ * at a time. Other SDK clients and sessions remain usable in that home.
1635
+ * Select localServer, githubEnvironment, or both. An empty localServer
1636
+ * selects 127.0.0.1 on an available port. `onExit` runs at most once; owner disconnection cannot
1637
+ * acknowledge listener cleanup over the disconnected transport.
1638
+ *
1639
+ * @experimental
1640
+ */
1641
+ async startAhpHost(options) {
1642
+ if (!options?.localServer && !options?.githubEnvironment) {
1643
+ throw new Error("At least one of localServer or githubEnvironment is required");
1644
+ }
1645
+ if (this.state !== "connected") {
1646
+ await this.start();
1647
+ }
1648
+ const rpc = this.rpc;
1649
+ const hostId = randomUUID();
1650
+ const {
1651
+ localServer,
1652
+ githubEnvironment,
1653
+ onExit,
1654
+ createSession,
1655
+ resumeSession,
1656
+ onSessionReleased
1657
+ } = options;
1658
+ if (createSession || resumeSession) {
1659
+ this.hostSessionFactories.set(hostId, {
1660
+ createSession,
1661
+ resumeSession,
1662
+ onSessionReleased
1663
+ });
1664
+ }
1665
+ if (onExit) {
1666
+ this.hostExitCallbacks.set(hostId, onExit);
1667
+ }
1668
+ try {
1669
+ const info = await rpc.host.start({
1670
+ hostId,
1671
+ localServer,
1672
+ githubEnvironment,
1673
+ sessionFactory: createSession ? true : void 0,
1674
+ resumeFactory: resumeSession ? true : void 0
1675
+ });
1676
+ return new AhpHost(
1677
+ info,
1678
+ async () => {
1679
+ await rpc.host.dispose({ hostId });
1680
+ },
1681
+ (sessionId) => rpc.host.publishSession({ hostId, sessionId })
1682
+ );
1683
+ } catch (error) {
1684
+ this.hostExitCallbacks.delete(hostId);
1685
+ this.releaseHostSessions(hostId);
1686
+ throw error;
1687
+ }
1688
+ }
1689
+ handleHostExit(exit) {
1690
+ this.releaseHostSessions(exit.hostId);
1691
+ const onExit = this.hostExitCallbacks.get(exit.hostId);
1692
+ if (onExit) {
1693
+ this.hostExitCallbacks.delete(exit.hostId);
1694
+ const reportError = (error) => {
1695
+ console.error("AHP host exit callback failed", { hostId: exit.hostId, error });
1696
+ };
1697
+ try {
1698
+ void Promise.resolve(onExit(exit)).catch(reportError);
1699
+ } catch (error) {
1700
+ reportError(error);
1701
+ }
1702
+ }
1703
+ }
1704
+ disconnectHosts() {
1705
+ for (const hostId of /* @__PURE__ */ new Set([
1706
+ ...this.hostExitCallbacks.keys(),
1707
+ ...this.hostSessionFactories.keys()
1708
+ ])) {
1709
+ this.handleHostExit({
1710
+ hostId,
1711
+ reason: "ownerDisconnected",
1712
+ error: "Owner connection closed; runtime cleanup cannot be acknowledged on this connection."
1713
+ });
1714
+ }
1715
+ }
1716
+ releaseHostSessions(hostId) {
1717
+ this.hostSessionFactories.delete(hostId);
1718
+ for (const [handoffId, entry] of this.hostHandoffs) {
1719
+ if (entry.hostId === hostId) this.releaseHostSession(handoffId);
1720
+ }
1721
+ }
1722
+ releaseHostSession(handoffId) {
1723
+ const entry = this.hostHandoffs.get(handoffId);
1724
+ if (!entry) return;
1725
+ this.hostHandoffs.delete(handoffId);
1726
+ entry.released = true;
1727
+ entry.configs.clear();
1728
+ entry.controller.abort();
1729
+ if (entry.session) this.notifyHostSessionReleased(entry);
1730
+ }
1731
+ notifyHostSessionReleased(entry) {
1732
+ const { session, onReleased } = entry;
1733
+ entry.session = void 0;
1734
+ if (session && onReleased) {
1735
+ try {
1736
+ void Promise.resolve(onReleased(session)).catch(() => {
1737
+ console.error("AHP session release callback failed");
1738
+ });
1739
+ } catch {
1740
+ console.error("AHP session release callback failed");
1741
+ }
1742
+ }
1743
+ }
1744
+ async createHostSession(params) {
1745
+ const options = this.hostSessionFactories.get(params.hostId);
1746
+ if (!(params.resume ? options?.resumeSession : options?.createSession) || this.hostHandoffs.has(params.handoffId)) {
1747
+ throw new Error("AHP session factory is unavailable or handoff already exists");
1748
+ }
1749
+ const entry = {
1750
+ hostId: params.hostId,
1751
+ requestedSessionId: params.config.sessionId,
1752
+ controller: new AbortController(),
1753
+ configs: /* @__PURE__ */ new Map(),
1754
+ released: false,
1755
+ onReleased: options.onSessionReleased
1756
+ };
1757
+ this.hostHandoffs.set(params.handoffId, entry);
1758
+ const { configDir, ...config } = params.config;
1759
+ if (configDir !== void 0) config.configDirectory = configDir;
1760
+ const expected = structuredClone(config);
1761
+ const cancelled = new Promise((_resolve, reject) => {
1762
+ entry.controller.signal.addEventListener(
1763
+ "abort",
1764
+ () => {
1765
+ reject(new Error("AHP session handoff ended"));
1766
+ },
1767
+ { once: true }
1768
+ );
1769
+ });
1770
+ const materialized = Promise.resolve().then(() => {
1771
+ if (params.resume) {
1772
+ const { sessionId, ...resumeConfig } = config;
1773
+ if (!sessionId) throw new Error("AHP resume requires sessionId");
1774
+ return options.resumeSession({
1775
+ sessionId,
1776
+ config: resumeConfig,
1777
+ signal: entry.controller.signal
1778
+ });
1779
+ }
1780
+ return options.createSession({
1781
+ config,
1782
+ signal: entry.controller.signal
1783
+ });
1784
+ }).then((session) => {
1785
+ entry.session = session;
1786
+ const actual = entry.configs.get(session);
1787
+ entry.configs.clear();
1788
+ if (entry.released) {
1789
+ this.notifyHostSessionReleased(entry);
1790
+ throw new Error("AHP session handoff ended");
1791
+ }
1792
+ if (this.sessions.get(session.sessionId) !== session || session.sessionId !== expected.sessionId) {
1793
+ throw new Error(
1794
+ "AHP callback must return the requested session from this client"
1795
+ );
1796
+ }
1797
+ const contains = (value, required) => {
1798
+ if (required && typeof required === "object" && !Array.isArray(required)) {
1799
+ return !!value && typeof value === "object" && Object.entries(required).every(
1800
+ ([key, child]) => contains(value[key], child)
1801
+ );
1802
+ }
1803
+ return JSON.stringify(value) === JSON.stringify(required);
1804
+ };
1805
+ if ((!params.resume || actual !== void 0) && !contains(actual, expected)) {
1806
+ throw new Error(
1807
+ "AHP callback must preserve the supplied session configuration"
1808
+ );
1809
+ }
1810
+ return { sessionId: session.sessionId };
1811
+ });
1812
+ try {
1813
+ return await Promise.race([materialized, cancelled]);
1814
+ } catch (error) {
1815
+ this.releaseHostSession(params.handoffId);
1816
+ throw error;
1817
+ }
1818
+ }
1532
1819
  /**
1533
1820
  * Sends a ping request to the server to verify connectivity.
1534
1821
  *
@@ -2068,8 +2355,13 @@ stderr: ${stderrOutput}`
2068
2355
  case "inprocess":
2069
2356
  return this.connectViaFfi();
2070
2357
  case "tcp":
2071
- case "uri":
2072
2358
  return this.connectViaTcp();
2359
+ case "uri": {
2360
+ const { host, port } = this.parseCliUrl(this.connectionConfig.url);
2361
+ this.actualHost = host;
2362
+ this.runtimePort = port;
2363
+ return this.connectViaTcp();
2364
+ }
2073
2365
  }
2074
2366
  }
2075
2367
  /** Starts the in-process FFI runtime with SDK-managed typed options. */
@@ -2277,6 +2569,18 @@ stderr: ${stderrOutput}` : ""}`
2277
2569
  (params, token) => confirmation.confirm(params, token)
2278
2570
  );
2279
2571
  }
2572
+ this.connection.onRequest(
2573
+ "host.materializeSession",
2574
+ (params) => this.createHostSession(params)
2575
+ );
2576
+ this.connection.onNotification(
2577
+ "host.sessionReleased",
2578
+ (params) => {
2579
+ if (this.hostHandoffs.get(params.handoffId)?.hostId === params.hostId) {
2580
+ this.releaseHostSession(params.handoffId);
2581
+ }
2582
+ }
2583
+ );
2280
2584
  this.connection.onRequest(
2281
2585
  "hooks.invoke",
2282
2586
  async (params) => {
@@ -2284,20 +2588,27 @@ stderr: ${stderrOutput}` : ""}`
2284
2588
  }
2285
2589
  );
2286
2590
  const connection = this.connection;
2591
+ const messageWriter = this.messageWriter;
2287
2592
  const markDisconnected = () => {
2288
2593
  if (this.connection !== connection) {
2594
+ connection.dispose();
2289
2595
  return;
2290
2596
  }
2291
2597
  this.connectionClosed = true;
2292
2598
  this.state = "disconnected";
2599
+ this.disconnectHosts();
2293
2600
  for (const session of this.sessions.values()) {
2294
2601
  session._markDisconnected();
2295
2602
  }
2296
2603
  this.sessions.clear();
2297
2604
  this.githubTokenProviders.clear();
2298
2605
  this.requestAdapter?.cancelPending();
2606
+ connection.dispose();
2299
2607
  };
2300
- this.connection.onClose(markDisconnected);
2608
+ this.connection.onClose(() => {
2609
+ if (messageWriter) messageWriter.suppressWriteErrors = true;
2610
+ void connection.drain().then(markDisconnected);
2611
+ });
2301
2612
  this.connection.onError(() => {
2302
2613
  if (this.connection === connection) {
2303
2614
  this.state = "disconnected";