Skip to content

Commit 9d64590

Browse files
Improve extension API via joinSession
1 parent a33c6e1 commit 9d64590

5 files changed

Lines changed: 87 additions & 63 deletions

File tree

nodejs/docs/agent-author.md

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ For user-scoped extensions (persist across all repos), add `location: "user"`.
2020
Modify the generated `extension.mjs` using `edit` or `create` tools. The file must:
2121
- Be named `extension.mjs` (only `.mjs` is supported)
2222
- Use ES module syntax (`import`/`export`)
23-
- Call `extension.resumeSession(process.env.SESSION_ID, { ... })`
24-
- Set `disableResume: true`
23+
- Call `joinSession({ ... })`
2524

2625
### Step 3: Reload extensions
2726

@@ -61,10 +60,9 @@ Discovery rules:
6160

6261
```js
6362
import { approveAll } from "@github/copilot-sdk";
64-
import { extension } from "@github/copilot-sdk/extension";
63+
import { joinSession } from "@github/copilot-sdk/extension";
6564

66-
await extension.resumeSession(process.env.SESSION_ID, {
67-
disableResume: true, // Required — extensions attach to existing sessions
65+
await joinSession({
6866
onPermissionRequest: approveAll, // Required — handle permission requests
6967
tools: [], // Optional — custom tools
7068
hooks: {}, // Optional — lifecycle hooks
@@ -194,7 +192,7 @@ All handlers may return `void`/`undefined` (no-op) or an output object.
194192

195193
## Session Object
196194

197-
After `resumeSession()`, the returned `session` provides:
195+
After `joinSession()`, the returned `session` provides:
198196

199197
### session.send(options)
200198

@@ -259,10 +257,9 @@ Low-level typed RPC access to all session APIs (model, mode, plan, workspace, et
259257

260258
## Gotchas
261259

262-
1. **stdout is reserved for JSON-RPC.** Don't use `console.log()` — it will corrupt the protocol. Use `session.log()` to surface messages to the user.
263-
2. **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize.
264-
3. **`disableResume: true` is required.** Extensions always attach to existing sessions.
265-
4. **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops.
266-
5. **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions.
267-
6. **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported.
268-
7. **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message.
260+
- **stdout is reserved for JSON-RPC.** Don't use `console.log()` — it will corrupt the protocol. Use `session.log()` to surface messages to the user.
261+
- **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize.
262+
- **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops.
263+
- **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions.
264+
- **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported.
265+
- **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message.

nodejs/docs/examples.md

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,16 @@ Every extension starts with the same boilerplate:
88

99
```js
1010
import { approveAll } from "@github/copilot-sdk";
11-
import { extension } from "@github/copilot-sdk/extension";
11+
import { joinSession } from "@github/copilot-sdk/extension";
1212

13-
const session = await extension.resumeSession(process.env.SESSION_ID, {
14-
disableResume: true,
13+
const session = await joinSession({
1514
onPermissionRequest: approveAll,
1615
hooks: { /* ... */ },
1716
tools: [ /* ... */ ],
1817
});
1918
```
2019

21-
`resumeSession` returns a `CopilotSession` object you can use to send messages and subscribe to events.
20+
`joinSession` returns a `CopilotSession` object you can use to send messages and subscribe to events.
2221

2322
> **Platform notes (Windows vs macOS/Linux):**
2423
> - Use `process.platform === "win32"` to detect Windows at runtime.
@@ -33,8 +32,7 @@ const session = await extension.resumeSession(process.env.SESSION_ID, {
3332
Use `session.log()` to surface messages to the user in the CLI timeline:
3433

3534
```js
36-
const session = await extension.resumeSession(process.env.SESSION_ID, {
37-
disableResume: true,
35+
const session = await joinSession({
3836
onPermissionRequest: approveAll,
3937
hooks: {
4038
onSessionStart: async () => {
@@ -337,7 +335,7 @@ hooks: {
337335
338336
## Session Events
339337
340-
After calling `resumeSession`, use `session.on()` to react to events in real time.
338+
After calling `joinSession`, use `session.on()` to react to events in real time.
341339
342340
### Listening to a specific event type
343341
@@ -384,8 +382,7 @@ function copyToClipboard(text) {
384382
proc.stdin.end();
385383
}
386384
387-
const session = await extension.resumeSession(process.env.SESSION_ID, {
388-
disableResume: true,
385+
const session = await joinSession({
389386
onPermissionRequest: approveAll,
390387
hooks: {
391388
onUserPromptSubmitted: async (input) => {
@@ -429,13 +426,12 @@ Correlate `tool.execution_start` / `tool.execution_complete` events by `toolCall
429426
import { existsSync, watchFile, readFileSync } from "node:fs";
430427
import { join } from "node:path";
431428
import { approveAll } from "@github/copilot-sdk";
432-
import { extension } from "@github/copilot-sdk/extension";
429+
import { joinSession } from "@github/copilot-sdk/extension";
433430
434431
const agentEdits = new Set(); // toolCallIds for in-flight agent edits
435432
const recentAgentPaths = new Set(); // paths recently written by the agent
436433
437-
const session = await extension.resumeSession(process.env.SESSION_ID, {
438-
disableResume: true,
434+
const session = await joinSession({
439435
onPermissionRequest: approveAll,
440436
});
441437
@@ -485,12 +481,11 @@ Filter out agent edits by tracking `tool.execution_start` / `tool.execution_comp
485481
import { watch, readFileSync, statSync } from "node:fs";
486482
import { join, relative, resolve } from "node:path";
487483
import { approveAll } from "@github/copilot-sdk";
488-
import { extension } from "@github/copilot-sdk/extension";
484+
import { joinSession } from "@github/copilot-sdk/extension";
489485
490486
const agentEditPaths = new Set();
491487
492-
const session = await extension.resumeSession(process.env.SESSION_ID, {
493-
disableResume: true,
488+
const session = await joinSession({
494489
onPermissionRequest: approveAll,
495490
});
496491
@@ -567,7 +562,7 @@ await session.send({
567562
### Custom permission logic
568563
569564
```js
570-
const session = await extension.resumeSession(process.env.SESSION_ID, {
565+
const session = await joinSession({
571566
onPermissionRequest: async (request) => {
572567
if (request.kind === "shell") {
573568
// request.fullCommandText has the shell command
@@ -586,7 +581,7 @@ const session = await extension.resumeSession(process.env.SESSION_ID, {
586581
Register `onUserInputRequest` to enable the agent's `ask_user` tool:
587582
588583
```js
589-
const session = await extension.resumeSession(process.env.SESSION_ID, {
584+
const session = await joinSession({
590585
onPermissionRequest: approveAll,
591586
onUserInputRequest: async (request) => {
592587
// request.question has the agent's question
@@ -605,7 +600,7 @@ An extension that combines tools, hooks, and events.
605600
```js
606601
import { execFile, exec } from "node:child_process";
607602
import { approveAll } from "@github/copilot-sdk";
608-
import { extension } from "@github/copilot-sdk/extension";
603+
import { joinSession } from "@github/copilot-sdk/extension";
609604
610605
const isWindows = process.platform === "win32";
611606
let copyNextResponse = false;
@@ -621,8 +616,7 @@ function openInEditor(filePath) {
621616
else execFile("code", [filePath], () => {});
622617
}
623618
624-
const session = await extension.resumeSession(process.env.SESSION_ID, {
625-
disableResume: true,
619+
const session = await joinSession({
626620
onPermissionRequest: approveAll,
627621
hooks: {
628622
onUserPromptSubmitted: async (input) => {

nodejs/docs/extensions.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run a
1818

1919
1. **Discovery**: The CLI scans `.github/extensions/` (project) and the user's copilot config extensions directory for subdirectories containing `extension.mjs`.
2020
2. **Launch**: Each extension is forked as a child process with `@github/copilot-sdk` available via an automatic module resolver.
21-
3. **Connection**: The extension calls `extension.resumeSession()` which establishes a JSON-RPC connection over stdio to the CLI.
21+
3. **Connection**: The extension calls `joinSession()` which establishes a JSON-RPC connection over stdio to the CLI and attaches to the user's current foreground session.
2222
4. **Registration**: Tools and hooks declared in the session options are registered with the CLI and become available to the agent.
23-
5. **Lifecycle**: Extensions are reloaded on `/clear` (new session) and stopped on CLI exit (SIGTERM, then SIGKILL after 5s).
23+
5. **Lifecycle**: Extensions are reloaded on `/clear` (or if the foreground session is replaced) and stopped on CLI exit (SIGTERM, then SIGKILL after 5s).
2424

2525
## File Structure
2626

@@ -33,21 +33,23 @@ Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run a
3333
- Only `.mjs` files are supported (ES modules). The file must be named `extension.mjs`.
3434
- Each extension lives in its own subdirectory.
3535
- The `@github/copilot-sdk` import is resolved automatically — you don't install it.
36-
- `process.env.SESSION_ID` provides the session ID to connect to.
3736

3837
## The SDK
3938

4039
Extensions use `@github/copilot-sdk` for all interactions with the CLI:
4140

4241
```js
4342
import { approveAll } from "@github/copilot-sdk";
44-
import { extension } from "@github/copilot-sdk/extension";
43+
import { joinSession } from "@github/copilot-sdk/extension";
4544

46-
const session = await extension.resumeSession(process.env.SESSION_ID, {
47-
disableResume: true,
45+
const session = await joinSession({
4846
onPermissionRequest: approveAll,
49-
tools: [ /* ... */ ],
50-
hooks: { /* ... */ },
47+
tools: [
48+
/* ... */
49+
],
50+
hooks: {
51+
/* ... */
52+
},
5153
});
5254
```
5355

nodejs/src/client.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ function toJsonSchema(parameters: Tool["parameters"]): Record<string, unknown> |
7777
return parameters;
7878
}
7979

80+
function getNodeExecPath(): string {
81+
if (process.versions.bun) {
82+
return "node";
83+
}
84+
return process.execPath;
85+
}
86+
87+
/**
88+
* Gets the path to the bundled CLI from the @github/copilot package.
89+
* Uses index.js directly rather than npm-loader.js (which spawns the native binary).
90+
*/
91+
function getBundledCliPath(): string {
92+
// Find the actual location of the @github/copilot package by resolving its sdk export
93+
const sdkUrl = import.meta.resolve("@github/copilot/sdk");
94+
const sdkPath = fileURLToPath(sdkUrl);
95+
// sdkPath is like .../node_modules/@github/copilot/sdk/index.js
96+
// Go up two levels to get the package root, then append index.js
97+
return join(dirname(dirname(sdkPath)), "index.js");
98+
}
99+
80100
/**
81101
* Main client for interacting with the Copilot CLI.
82102
*
@@ -110,27 +130,6 @@ function toJsonSchema(parameters: Tool["parameters"]): Record<string, unknown> |
110130
* await client.stop();
111131
* ```
112132
*/
113-
114-
function getNodeExecPath(): string {
115-
if (process.versions.bun) {
116-
return "node";
117-
}
118-
return process.execPath;
119-
}
120-
121-
/**
122-
* Gets the path to the bundled CLI from the @github/copilot package.
123-
* Uses index.js directly rather than npm-loader.js (which spawns the native binary).
124-
*/
125-
function getBundledCliPath(): string {
126-
// Find the actual location of the @github/copilot package by resolving its sdk export
127-
const sdkUrl = import.meta.resolve("@github/copilot/sdk");
128-
const sdkPath = fileURLToPath(sdkUrl);
129-
// sdkPath is like .../node_modules/@github/copilot/sdk/index.js
130-
// Go up two levels to get the package root, then append index.js
131-
return join(dirname(dirname(sdkPath)), "index.js");
132-
}
133-
134133
export class CopilotClient {
135134
private cliProcess: ChildProcess | null = null;
136135
private connection: MessageConnection | null = null;

nodejs/src/extension.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,37 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
import { CopilotClient } from "./client.js";
6+
import type { CopilotSession } from "./session.js";
7+
import type { ResumeSessionConfig } from "./types.js";
68

7-
export const extension = new CopilotClient({ isChildProcess: true });
9+
/**
10+
* Joins the current foreground session.
11+
*
12+
* @param config - Configuration to add to the session
13+
* @returns A promise that resolves with the joined session
14+
*
15+
* @example
16+
* ```typescript
17+
* import { approveAll } from "@github/copilot-sdk";
18+
* import { joinSession } from "@github/copilot-sdk/extension";
19+
*
20+
* const session = await joinSession({
21+
* onPermissionRequest: approveAll,
22+
* tools: [myTool],
23+
* });
24+
* ```
25+
*/
26+
export async function joinSession(config: ResumeSessionConfig): Promise<CopilotSession> {
27+
const sessionId = process.env.SESSION_ID;
28+
if (!sessionId) {
29+
throw new Error(
30+
"joinSession() is intended for extensions running as child processes of the Copilot CLI."
31+
);
32+
}
33+
34+
const client = new CopilotClient({ isChildProcess: true });
35+
return client.resumeSession(sessionId, {
36+
...config,
37+
disableResume: config.disableResume ?? true,
38+
});
39+
}

0 commit comments

Comments
 (0)