@github/copilot-sdk
Advanced tools
| # Agent Extension Authoring Guide | ||
| A precise, step-by-step reference for agents writing Copilot CLI extensions programmatically. | ||
| ## Workflow | ||
| ### Step 1: Scaffold the extension | ||
| Use the `extensions_manage` tool with `operation: "scaffold"`: | ||
| ``` | ||
| extensions_manage({ operation: "scaffold", name: "my-extension" }) | ||
| ``` | ||
| This creates `.github/extensions/my-extension/extension.mjs` with a working skeleton. | ||
| For user-scoped extensions (persist across all repos), add `location: "user"`. | ||
| ### Step 2: Edit the extension file | ||
| Modify the generated `extension.mjs` using `edit` or `create` tools. The file must: | ||
| - Be named `extension.mjs` (only `.mjs` is supported) | ||
| - Use ES module syntax (`import`/`export`) | ||
| - Call `joinSession({ ... })` | ||
| ### Step 3: Reload extensions | ||
| ``` | ||
| extensions_reload({}) | ||
| ``` | ||
| This stops all running extensions and re-discovers/re-launches them. New tools are available immediately in the same turn (mid-turn refresh). | ||
| ### Step 4: Verify | ||
| ``` | ||
| extensions_manage({ operation: "list" }) | ||
| extensions_manage({ operation: "inspect", name: "my-extension" }) | ||
| ``` | ||
| Check that the extension loaded successfully and isn't marked as "failed". | ||
| --- | ||
| ## File Structure | ||
| ``` | ||
| .github/extensions/<name>/extension.mjs | ||
| ``` | ||
| Discovery rules: | ||
| - The CLI scans `.github/extensions/` relative to the git root | ||
| - It also scans the user's copilot config extensions directory | ||
| - Only immediate subdirectories are checked (not recursive) | ||
| - Each subdirectory must contain a file named `extension.mjs` | ||
| - Project extensions shadow user extensions on name collision | ||
| --- | ||
| ## Minimal Skeleton | ||
| ```js | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| await joinSession({ | ||
| onPermissionRequest: approveAll, // Required — handle permission requests | ||
| tools: [], // Optional — custom tools | ||
| hooks: {}, // Optional — lifecycle hooks | ||
| }); | ||
| ``` | ||
| --- | ||
| ## Registering Tools | ||
| ```js | ||
| tools: [ | ||
| { | ||
| name: "tool_name", // Required. Must be globally unique across all extensions. | ||
| description: "What it does", // Required. Shown to the agent in tool descriptions. | ||
| parameters: { // Optional. JSON Schema for the arguments. | ||
| type: "object", | ||
| properties: { | ||
| arg1: { type: "string", description: "..." }, | ||
| }, | ||
| required: ["arg1"], | ||
| }, | ||
| handler: async (args, invocation) => { | ||
| // args: parsed arguments matching the schema | ||
| // invocation.sessionId: current session ID | ||
| // invocation.toolCallId: unique call ID | ||
| // invocation.toolName: this tool's name | ||
| // | ||
| // Return value: string or ToolResultObject | ||
| // string → treated as success | ||
| // { textResultForLlm, resultType } → structured result | ||
| // resultType: "success" | "failure" | "rejected" | "denied" | ||
| return `Result: ${args.arg1}`; | ||
| }, | ||
| }, | ||
| ] | ||
| ``` | ||
| **Constraints:** | ||
| - Tool names must be unique across ALL loaded extensions. Collisions cause the second extension to fail to load. | ||
| - Handler must return a string or `{ textResultForLlm: string, resultType?: string }`. | ||
| - Handler receives `(args, invocation)` — the second argument has `sessionId`, `toolCallId`, `toolName`. | ||
| - Use `session.log()` to surface messages to the user. Don't use `console.log()` (stdout is reserved for JSON-RPC). | ||
| --- | ||
| ## Registering Hooks | ||
| ```js | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input, invocation) => { ... }, | ||
| onPreToolUse: async (input, invocation) => { ... }, | ||
| onPostToolUse: async (input, invocation) => { ... }, | ||
| onSessionStart: async (input, invocation) => { ... }, | ||
| onSessionEnd: async (input, invocation) => { ... }, | ||
| onErrorOccurred: async (input, invocation) => { ... }, | ||
| } | ||
| ``` | ||
| All hook inputs include `timestamp` (unix ms) and `cwd` (working directory). | ||
| All handlers receive `invocation: { sessionId: string }` as the second argument. | ||
| All handlers may return `void`/`undefined` (no-op) or an output object. | ||
| ### onUserPromptSubmitted | ||
| **Input:** `{ prompt: string, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `modifiedPrompt` | `string` | Replaces the user's prompt | | ||
| | `additionalContext` | `string` | Appended as hidden context the agent sees | | ||
| ### onPreToolUse | ||
| **Input:** `{ toolName: string, toolArgs: unknown, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `permissionDecision` | `"allow" \| "deny" \| "ask"` | Override the permission check | | ||
| | `permissionDecisionReason` | `string` | Shown to user if denied | | ||
| | `modifiedArgs` | `unknown` | Replaces the tool arguments | | ||
| | `additionalContext` | `string` | Injected into the conversation | | ||
| ### onPostToolUse | ||
| **Input:** `{ toolName: string, toolArgs: unknown, toolResult: ToolResultObject, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `modifiedResult` | `ToolResultObject` | Replaces the tool result | | ||
| | `additionalContext` | `string` | Injected into the conversation | | ||
| ### onSessionStart | ||
| **Input:** `{ source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `additionalContext` | `string` | Injected as initial context | | ||
| ### onSessionEnd | ||
| **Input:** `{ reason: "complete" \| "error" \| "abort" \| "timeout" \| "user_exit", finalMessage?: string, error?: string, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `sessionSummary` | `string` | Summary for session persistence | | ||
| | `cleanupActions` | `string[]` | Cleanup descriptions | | ||
| ### onErrorOccurred | ||
| **Input:** `{ error: string, errorContext: "model_call" \| "tool_execution" \| "system" \| "user_input", recoverable: boolean, timestamp, cwd }` | ||
| **Output (all fields optional):** | ||
| | Field | Type | Effect | | ||
| |-------|------|--------| | ||
| | `errorHandling` | `"retry" \| "skip" \| "abort"` | How to handle the error | | ||
| | `retryCount` | `number` | Max retries (when errorHandling is "retry") | | ||
| | `userNotification` | `string` | Message shown to the user | | ||
| --- | ||
| ## Session Object | ||
| After `joinSession()`, the returned `session` provides: | ||
| ### session.send(options) | ||
| Send a message programmatically: | ||
| ```js | ||
| await session.send({ prompt: "Analyze the test results." }); | ||
| await session.send({ | ||
| prompt: "Review this file", | ||
| attachments: [{ type: "file", path: "./src/index.ts" }], | ||
| }); | ||
| ``` | ||
| ### session.sendAndWait(options, timeout?) | ||
| Send and block until the agent finishes (resolves on `session.idle`): | ||
| ```js | ||
| const response = await session.sendAndWait({ prompt: "What is 2+2?" }); | ||
| // response?.data.content contains the agent's reply | ||
| ``` | ||
| ### session.log(message, options?) | ||
| Log to the CLI timeline: | ||
| ```js | ||
| await session.log("Extension ready"); | ||
| await session.log("Rate limit approaching", { level: "warning" }); | ||
| await session.log("Connection failed", { level: "error" }); | ||
| await session.log("Processing...", { ephemeral: true }); // transient, not persisted | ||
| ``` | ||
| ### session.on(eventType, handler) | ||
| Subscribe to session events. Returns an unsubscribe function. | ||
| ```js | ||
| const unsub = session.on("tool.execution_complete", (event) => { | ||
| // event.data.toolName, event.data.success, event.data.result | ||
| }); | ||
| ``` | ||
| ### Key Event Types | ||
| | Event | Key Data Fields | | ||
| |-------|----------------| | ||
| | `assistant.message` | `content`, `messageId` | | ||
| | `tool.execution_start` | `toolCallId`, `toolName`, `arguments` | | ||
| | `tool.execution_complete` | `toolCallId`, `toolName`, `success`, `result`, `error` | | ||
| | `user.message` | `content`, `attachments`, `source` | | ||
| | `session.idle` | `backgroundTasks` | | ||
| | `session.error` | `errorType`, `message`, `stack` | | ||
| | `permission.requested` | `requestId`, `permissionRequest.kind` | | ||
| | `session.shutdown` | `shutdownType`, `totalPremiumRequests` | | ||
| ### session.workspacePath | ||
| Path to the session workspace directory (checkpoints, plan.md, files/). `undefined` if infinite sessions disabled. | ||
| ### session.rpc | ||
| Low-level typed RPC access to all session APIs (model, mode, plan, workspace, etc.). | ||
| --- | ||
| ## Gotchas | ||
| - **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. | ||
| - **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize. | ||
| - **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops. | ||
| - **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions. | ||
| - **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported. | ||
| - **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message. |
+681
| # Copilot CLI Extension Examples | ||
| A practical guide to writing extensions using the `@github/copilot-sdk` extension API. | ||
| ## Extension Skeleton | ||
| Every extension starts with the same boilerplate: | ||
| ```js | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| hooks: { /* ... */ }, | ||
| tools: [ /* ... */ ], | ||
| }); | ||
| ``` | ||
| `joinSession` returns a `CopilotSession` object you can use to send messages and subscribe to events. | ||
| > **Platform notes (Windows vs macOS/Linux):** | ||
| > - Use `process.platform === "win32"` to detect Windows at runtime. | ||
| > - Clipboard: `pbcopy` on macOS, `clip` on Windows. | ||
| > - Use `exec()` instead of `execFile()` for `.cmd` scripts like `code`, `npx`, `npm` on Windows. | ||
| > - PowerShell stderr redirection uses `*>&1` instead of `2>&1`. | ||
| --- | ||
| ## Logging to the Timeline | ||
| Use `session.log()` to surface messages to the user in the CLI timeline: | ||
| ```js | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| hooks: { | ||
| onSessionStart: async () => { | ||
| await session.log("My extension loaded"); | ||
| }, | ||
| onPreToolUse: async (input) => { | ||
| if (input.toolName === "bash") { | ||
| await session.log(`Running: ${input.toolArgs?.command}`, { ephemeral: true }); | ||
| } | ||
| }, | ||
| }, | ||
| tools: [], | ||
| }); | ||
| ``` | ||
| Levels: `"info"` (default), `"warning"`, `"error"`. Set `ephemeral: true` for transient messages that aren't persisted. | ||
| --- | ||
| ## Registering Custom Tools | ||
| Tools are functions the agent can call. Define them with a name, description, JSON Schema parameters, and a handler. | ||
| ### Basic tool | ||
| ```js | ||
| tools: [ | ||
| { | ||
| name: "my_tool", | ||
| description: "Does something useful", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| input: { type: "string", description: "The input value" }, | ||
| }, | ||
| required: ["input"], | ||
| }, | ||
| handler: async (args) => { | ||
| return `Processed: ${args.input}`; | ||
| }, | ||
| }, | ||
| ] | ||
| ``` | ||
| ### Tool that invokes an external shell command | ||
| ```js | ||
| import { execFile } from "node:child_process"; | ||
| { | ||
| name: "run_command", | ||
| description: "Runs a shell command and returns its output", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| command: { type: "string", description: "The command to run" }, | ||
| }, | ||
| required: ["command"], | ||
| }, | ||
| handler: async (args) => { | ||
| const isWindows = process.platform === "win32"; | ||
| const shell = isWindows ? "powershell" : "bash"; | ||
| const shellArgs = isWindows | ||
| ? ["-NoProfile", "-Command", args.command] | ||
| : ["-c", args.command]; | ||
| return new Promise((resolve) => { | ||
| execFile(shell, shellArgs, (err, stdout, stderr) => { | ||
| if (err) resolve(`Error: ${stderr || err.message}`); | ||
| else resolve(stdout); | ||
| }); | ||
| }); | ||
| }, | ||
| } | ||
| ``` | ||
| ### Tool that calls an external API | ||
| ```js | ||
| { | ||
| name: "fetch_data", | ||
| description: "Fetches data from an API endpoint", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| url: { type: "string", description: "The URL to fetch" }, | ||
| }, | ||
| required: ["url"], | ||
| }, | ||
| handler: async (args) => { | ||
| const res = await fetch(args.url); | ||
| if (!res.ok) return `Error: HTTP ${res.status}`; | ||
| return await res.text(); | ||
| }, | ||
| } | ||
| ``` | ||
| ### Tool handler invocation context | ||
| The handler receives a second argument with invocation metadata: | ||
| ```js | ||
| handler: async (args, invocation) => { | ||
| // invocation.sessionId — current session ID | ||
| // invocation.toolCallId — unique ID for this tool call | ||
| // invocation.toolName — name of the tool being called | ||
| return "done"; | ||
| } | ||
| ``` | ||
| --- | ||
| ## Hooks | ||
| Hooks intercept and modify behavior at key lifecycle points. Register them in the `hooks` option. | ||
| ### Available Hooks | ||
| | Hook | Fires When | Can Modify | | ||
| |------|-----------|------------| | ||
| | `onUserPromptSubmitted` | User sends a message | The prompt text, add context | | ||
| | `onPreToolUse` | Before a tool executes | Tool args, permission decision, add context | | ||
| | `onPostToolUse` | After a tool executes | Tool result, add context | | ||
| | `onSessionStart` | Session starts or resumes | Add context, modify config | | ||
| | `onSessionEnd` | Session ends | Cleanup actions, summary | | ||
| | `onErrorOccurred` | An error occurs | Error handling strategy (retry/skip/abort) | | ||
| All hook inputs include `timestamp` (unix ms) and `cwd` (working directory). | ||
| ### Modifying the user's message | ||
| Use `onUserPromptSubmitted` to rewrite or augment what the user typed before the agent sees it. | ||
| ```js | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input) => { | ||
| // Rewrite the prompt | ||
| return { modifiedPrompt: input.prompt.toUpperCase() }; | ||
| }, | ||
| } | ||
| ``` | ||
| ### Injecting additional context into every message | ||
| Return `additionalContext` to silently append instructions the agent will follow. | ||
| ```js | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input) => { | ||
| return { | ||
| additionalContext: "Always respond in bullet points. Follow our team coding standards.", | ||
| }; | ||
| }, | ||
| } | ||
| ``` | ||
| ### Sending a follow-up message based on a keyword | ||
| Use `session.send()` to programmatically inject a new user message. | ||
| ```js | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input) => { | ||
| if (/\\burgent\\b/i.test(input.prompt)) { | ||
| // Fire-and-forget a follow-up message | ||
| setTimeout(() => session.send({ prompt: "Please prioritize this." }), 0); | ||
| } | ||
| }, | ||
| } | ||
| ``` | ||
| > **Tip:** Guard against infinite loops if your follow-up message could re-trigger the same hook. | ||
| ### Blocking dangerous tool calls | ||
| Use `onPreToolUse` to inspect and optionally deny tool execution. | ||
| ```js | ||
| hooks: { | ||
| onPreToolUse: async (input) => { | ||
| if (input.toolName === "bash") { | ||
| const cmd = String(input.toolArgs?.command || ""); | ||
| if (/rm\\s+-rf/i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) { | ||
| return { | ||
| permissionDecision: "deny", | ||
| permissionDecisionReason: "Destructive commands are not allowed.", | ||
| }; | ||
| } | ||
| } | ||
| // Allow everything else | ||
| return { permissionDecision: "allow" }; | ||
| }, | ||
| } | ||
| ``` | ||
| ### Modifying tool arguments before execution | ||
| ```js | ||
| hooks: { | ||
| onPreToolUse: async (input) => { | ||
| if (input.toolName === "bash") { | ||
| const redirect = process.platform === "win32" ? "*>&1" : "2>&1"; | ||
| return { | ||
| modifiedArgs: { | ||
| ...input.toolArgs, | ||
| command: `${input.toolArgs.command} ${redirect}`, | ||
| }, | ||
| }; | ||
| } | ||
| }, | ||
| } | ||
| ``` | ||
| ### Reacting when the agent creates or edits a file | ||
| Use `onPostToolUse` to run side effects after a tool completes. | ||
| ```js | ||
| import { exec } from "node:child_process"; | ||
| hooks: { | ||
| onPostToolUse: async (input) => { | ||
| if (input.toolName === "create" || input.toolName === "edit") { | ||
| const filePath = input.toolArgs?.path; | ||
| if (filePath) { | ||
| // Open the file in VS Code | ||
| exec(`code "${filePath}"`, () => {}); | ||
| } | ||
| } | ||
| }, | ||
| } | ||
| ``` | ||
| ### Augmenting tool results with extra context | ||
| ```js | ||
| hooks: { | ||
| onPostToolUse: async (input) => { | ||
| if (input.toolName === "bash" && input.toolResult?.resultType === "failure") { | ||
| return { | ||
| additionalContext: "The command failed. Try a different approach.", | ||
| }; | ||
| } | ||
| }, | ||
| } | ||
| ``` | ||
| ### Running a linter after every file edit | ||
| ```js | ||
| import { exec } from "node:child_process"; | ||
| hooks: { | ||
| onPostToolUse: async (input) => { | ||
| if (input.toolName === "edit") { | ||
| const filePath = input.toolArgs?.path; | ||
| if (filePath?.endsWith(".ts")) { | ||
| const result = await new Promise((resolve) => { | ||
| exec(`npx eslint "${filePath}"`, (err, stdout) => { | ||
| resolve(err ? stdout : "No lint errors."); | ||
| }); | ||
| }); | ||
| return { additionalContext: `Lint result: ${result}` }; | ||
| } | ||
| } | ||
| }, | ||
| } | ||
| ``` | ||
| ### Handling errors with retry logic | ||
| ```js | ||
| hooks: { | ||
| onErrorOccurred: async (input) => { | ||
| if (input.recoverable && input.errorContext === "model_call") { | ||
| return { errorHandling: "retry", retryCount: 2 }; | ||
| } | ||
| return { | ||
| errorHandling: "abort", | ||
| userNotification: `An error occurred: ${input.error}`, | ||
| }; | ||
| }, | ||
| } | ||
| ``` | ||
| ### Session lifecycle hooks | ||
| ```js | ||
| hooks: { | ||
| onSessionStart: async (input) => { | ||
| // input.source is "startup", "resume", or "new" | ||
| return { additionalContext: "Remember to write tests for all changes." }; | ||
| }, | ||
| onSessionEnd: async (input) => { | ||
| // input.reason is "complete", "error", "abort", "timeout", or "user_exit" | ||
| }, | ||
| } | ||
| ``` | ||
| --- | ||
| ## Session Events | ||
| After calling `joinSession`, use `session.on()` to react to events in real time. | ||
| ### Listening to a specific event type | ||
| ```js | ||
| session.on("assistant.message", (event) => { | ||
| // event.data.content has the agent's response text | ||
| }); | ||
| ``` | ||
| ### Listening to all events | ||
| ```js | ||
| session.on((event) => { | ||
| // event.type and event.data are available for all events | ||
| }); | ||
| ``` | ||
| ### Unsubscribing from events | ||
| `session.on()` returns an unsubscribe function: | ||
| ```js | ||
| const unsubscribe = session.on("tool.execution_complete", (event) => { | ||
| // event.data.toolName, event.data.success, event.data.result, event.data.error | ||
| }); | ||
| // Later, stop listening | ||
| unsubscribe(); | ||
| ``` | ||
| ### Example: Auto-copy agent responses to clipboard | ||
| Combine a hook (to detect a keyword) with a session event (to capture the response): | ||
| ```js | ||
| import { execFile } from "node:child_process"; | ||
| let copyNextResponse = false; | ||
| function copyToClipboard(text) { | ||
| const cmd = process.platform === "win32" ? "clip" : "pbcopy"; | ||
| const proc = execFile(cmd, [], () => {}); | ||
| proc.stdin.write(text); | ||
| proc.stdin.end(); | ||
| } | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input) => { | ||
| if (/\\bcopy\\b/i.test(input.prompt)) { | ||
| copyNextResponse = true; | ||
| } | ||
| }, | ||
| }, | ||
| tools: [], | ||
| }); | ||
| session.on("assistant.message", (event) => { | ||
| if (copyNextResponse) { | ||
| copyNextResponse = false; | ||
| copyToClipboard(event.data.content); | ||
| } | ||
| }); | ||
| ``` | ||
| ### Top 10 Most Useful Event Types | ||
| | Event Type | Description | Key Data Fields | | ||
| |-----------|-------------|-----------------| | ||
| | `assistant.message` | Agent's final response | `content`, `messageId`, `toolRequests` | | ||
| | `assistant.streaming_delta` | Token-by-token streaming (ephemeral) | `totalResponseSizeBytes` | | ||
| | `tool.execution_start` | A tool is about to run | `toolCallId`, `toolName`, `arguments` | | ||
| | `tool.execution_complete` | A tool finished running | `toolCallId`, `toolName`, `success`, `result`, `error` | | ||
| | `user.message` | User sent a message | `content`, `attachments`, `source` | | ||
| | `session.idle` | Session finished processing a turn | `backgroundTasks` | | ||
| | `session.error` | An error occurred | `errorType`, `message`, `stack` | | ||
| | `permission.requested` | Agent needs permission (shell, file write, etc.) | `requestId`, `permissionRequest.kind` | | ||
| | `session.shutdown` | Session is ending | `shutdownType`, `totalPremiumRequests`, `codeChanges` | | ||
| | `assistant.turn_start` | Agent begins a new thinking/response cycle | `turnId` | | ||
| ### Example: Detecting when the plan file is created or edited | ||
| Use `session.workspacePath` to locate the session's `plan.md`, then `fs.watchFile` to detect changes. | ||
| Correlate `tool.execution_start` / `tool.execution_complete` events by `toolCallId` to distinguish agent edits from user edits. | ||
| ```js | ||
| import { existsSync, watchFile, readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| const agentEdits = new Set(); // toolCallIds for in-flight agent edits | ||
| const recentAgentPaths = new Set(); // paths recently written by the agent | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| }); | ||
| const workspace = session.workspacePath; // e.g. ~/.copilot/session-state/<id> | ||
| if (workspace) { | ||
| const planPath = join(workspace, "plan.md"); | ||
| let lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; | ||
| // Track agent edits to suppress false triggers | ||
| session.on("tool.execution_start", (event) => { | ||
| if ((event.data.toolName === "edit" || event.data.toolName === "create") | ||
| && String(event.data.arguments?.path || "").endsWith("plan.md")) { | ||
| agentEdits.add(event.data.toolCallId); | ||
| recentAgentPaths.add(planPath); | ||
| } | ||
| }); | ||
| session.on("tool.execution_complete", (event) => { | ||
| if (agentEdits.delete(event.data.toolCallId)) { | ||
| setTimeout(() => { | ||
| recentAgentPaths.delete(planPath); | ||
| lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; | ||
| }, 2000); | ||
| } | ||
| }); | ||
| watchFile(planPath, { interval: 1000 }, () => { | ||
| if (recentAgentPaths.has(planPath) || agentEdits.size > 0) return; | ||
| const content = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null; | ||
| if (content === lastContent) return; | ||
| const wasCreated = lastContent === null && content !== null; | ||
| lastContent = content; | ||
| if (content !== null) { | ||
| session.send({ | ||
| prompt: `The plan was ${wasCreated ? "created" : "edited"} by the user.`, | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
| ``` | ||
| ### Example: Reacting when the user manually edits any file in the repo | ||
| Use `fs.watch` with `recursive: true` on `process.cwd()` to detect file changes. | ||
| Filter out agent edits by tracking `tool.execution_start` / `tool.execution_complete` events. | ||
| ```js | ||
| import { watch, readFileSync, statSync } from "node:fs"; | ||
| import { join, relative, resolve } from "node:path"; | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| const agentEditPaths = new Set(); | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| }); | ||
| const cwd = process.cwd(); | ||
| const IGNORE = new Set(["node_modules", ".git", "dist"]); | ||
| // Track agent file edits | ||
| session.on("tool.execution_start", (event) => { | ||
| if (event.data.toolName === "edit" || event.data.toolName === "create") { | ||
| const p = String(event.data.arguments?.path || ""); | ||
| if (p) agentEditPaths.add(resolve(p)); | ||
| } | ||
| }); | ||
| session.on("tool.execution_complete", (event) => { | ||
| // Clear after a delay to avoid race with fs.watch | ||
| const p = [...agentEditPaths].find((x) => x); // any tracked path | ||
| setTimeout(() => agentEditPaths.clear(), 3000); | ||
| }); | ||
| const debounce = new Map(); | ||
| watch(cwd, { recursive: true }, (eventType, filename) => { | ||
| if (!filename || eventType !== "change") return; | ||
| if (filename.split(/[\\\\\\/]/).some((p) => IGNORE.has(p))) return; | ||
| if (debounce.has(filename)) clearTimeout(debounce.get(filename)); | ||
| debounce.set(filename, setTimeout(() => { | ||
| debounce.delete(filename); | ||
| const fullPath = join(cwd, filename); | ||
| if (agentEditPaths.has(resolve(fullPath))) return; | ||
| try { if (!statSync(fullPath).isFile()) return; } catch { return; } | ||
| const relPath = relative(cwd, fullPath); | ||
| session.send({ | ||
| prompt: `The user edited \\`${relPath}\\`.`, | ||
| attachments: [{ type: "file", path: fullPath }], | ||
| }); | ||
| }, 500)); | ||
| }); | ||
| ``` | ||
| --- | ||
| ## Sending Messages Programmatically | ||
| ### Fire-and-forget | ||
| ```js | ||
| await session.send({ prompt: "Analyze the test results." }); | ||
| ``` | ||
| ### Send and wait for the response | ||
| ```js | ||
| const response = await session.sendAndWait({ prompt: "What is 2 + 2?" }); | ||
| // response?.data.content contains the agent's reply | ||
| ``` | ||
| ### Send with file attachments | ||
| ```js | ||
| await session.send({ | ||
| prompt: "Review this file", | ||
| attachments: [ | ||
| { type: "file", path: "./src/index.ts" }, | ||
| ], | ||
| }); | ||
| ``` | ||
| --- | ||
| ## Permission and User Input Handlers | ||
| ### Custom permission logic | ||
| ```js | ||
| const session = await joinSession({ | ||
| onPermissionRequest: async (request) => { | ||
| if (request.kind === "shell") { | ||
| // request.fullCommandText has the shell command | ||
| return { kind: "approved" }; | ||
| } | ||
| if (request.kind === "write") { | ||
| return { kind: "approved" }; | ||
| } | ||
| return { kind: "denied-by-rules" }; | ||
| }, | ||
| }); | ||
| ``` | ||
| ### Handling agent questions (ask_user) | ||
| Register `onUserInputRequest` to enable the agent's `ask_user` tool: | ||
| ```js | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| onUserInputRequest: async (request) => { | ||
| // request.question has the agent's question | ||
| // request.choices has the options (if multiple choice) | ||
| return { answer: "yes", wasFreeform: false }; | ||
| }, | ||
| }); | ||
| ``` | ||
| --- | ||
| ## Complete Example: Multi-Feature Extension | ||
| An extension that combines tools, hooks, and events. | ||
| ```js | ||
| import { execFile, exec } from "node:child_process"; | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| const isWindows = process.platform === "win32"; | ||
| let copyNextResponse = false; | ||
| function copyToClipboard(text) { | ||
| const proc = execFile(isWindows ? "clip" : "pbcopy", [], () => {}); | ||
| proc.stdin.write(text); | ||
| proc.stdin.end(); | ||
| } | ||
| function openInEditor(filePath) { | ||
| if (isWindows) exec(`code "${filePath}"`, () => {}); | ||
| else execFile("code", [filePath], () => {}); | ||
| } | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| hooks: { | ||
| onUserPromptSubmitted: async (input) => { | ||
| if (/\\bcopy this\\b/i.test(input.prompt)) { | ||
| copyNextResponse = true; | ||
| } | ||
| return { | ||
| additionalContext: "Follow our team style guide. Use 4-space indentation.", | ||
| }; | ||
| }, | ||
| onPreToolUse: async (input) => { | ||
| if (input.toolName === "bash") { | ||
| const cmd = String(input.toolArgs?.command || ""); | ||
| if (/rm\\s+-rf\\s+\\//i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) { | ||
| return { permissionDecision: "deny" }; | ||
| } | ||
| } | ||
| }, | ||
| onPostToolUse: async (input) => { | ||
| if (input.toolName === "create" || input.toolName === "edit") { | ||
| const filePath = input.toolArgs?.path; | ||
| if (filePath) openInEditor(filePath); | ||
| } | ||
| }, | ||
| }, | ||
| tools: [ | ||
| { | ||
| name: "copy_to_clipboard", | ||
| description: "Copies text to the system clipboard.", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| text: { type: "string", description: "Text to copy" }, | ||
| }, | ||
| required: ["text"], | ||
| }, | ||
| handler: async (args) => { | ||
| return new Promise((resolve) => { | ||
| const proc = execFile(isWindows ? "clip" : "pbcopy", [], (err) => { | ||
| if (err) resolve(`Error: ${err.message}`); | ||
| else resolve("Copied to clipboard."); | ||
| }); | ||
| proc.stdin.write(args.text); | ||
| proc.stdin.end(); | ||
| }); | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
| session.on("assistant.message", (event) => { | ||
| if (copyNextResponse) { | ||
| copyNextResponse = false; | ||
| copyToClipboard(event.data.content); | ||
| } | ||
| }); | ||
| session.on("tool.execution_complete", (event) => { | ||
| // event.data.success, event.data.toolName, event.data.result | ||
| }); | ||
| ``` | ||
| # Copilot CLI Extensions | ||
| Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run as separate Node.js processes that communicate with the CLI over JSON-RPC via stdio. | ||
| ## How Extensions Work | ||
| ``` | ||
| ┌─────────────────────┐ JSON-RPC / stdio ┌──────────────────────┐ | ||
| │ Copilot CLI │ ◄──────────────────────────────────► │ Extension Process │ | ||
| │ (parent process) │ tool calls, events, hooks │ (forked child) │ | ||
| │ │ │ │ | ||
| │ • Discovers exts │ │ • Registers tools │ | ||
| │ • Forks processes │ │ • Registers hooks │ | ||
| │ • Routes tool calls │ │ • Listens to events │ | ||
| │ • Manages lifecycle │ │ • Uses SDK APIs │ | ||
| └─────────────────────┘ └──────────────────────┘ | ||
| ``` | ||
| 1. **Discovery**: The CLI scans `.github/extensions/` (project) and the user's copilot config extensions directory for subdirectories containing `extension.mjs`. | ||
| 2. **Launch**: Each extension is forked as a child process with `@github/copilot-sdk` available via an automatic module resolver. | ||
| 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. | ||
| 4. **Registration**: Tools and hooks declared in the session options are registered with the CLI and become available to the agent. | ||
| 5. **Lifecycle**: Extensions are reloaded on `/clear` (or if the foreground session is replaced) and stopped on CLI exit (SIGTERM, then SIGKILL after 5s). | ||
| ## File Structure | ||
| ``` | ||
| .github/extensions/ | ||
| my-extension/ | ||
| extension.mjs ← Entry point (required, must be .mjs) | ||
| ``` | ||
| - Only `.mjs` files are supported (ES modules). The file must be named `extension.mjs`. | ||
| - Each extension lives in its own subdirectory. | ||
| - The `@github/copilot-sdk` import is resolved automatically — you don't install it. | ||
| ## The SDK | ||
| Extensions use `@github/copilot-sdk` for all interactions with the CLI: | ||
| ```js | ||
| import { approveAll } from "@github/copilot-sdk"; | ||
| import { joinSession } from "@github/copilot-sdk/extension"; | ||
| const session = await joinSession({ | ||
| onPermissionRequest: approveAll, | ||
| tools: [ | ||
| /* ... */ | ||
| ], | ||
| hooks: { | ||
| /* ... */ | ||
| }, | ||
| }); | ||
| ``` | ||
| The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information. | ||
| ## Further Reading | ||
| - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions | ||
| - `agent-author.md` — Step-by-step workflow for agents authoring extensions programmatically |
+38
-1
| import { createServerRpc } from "./generated/rpc.js"; | ||
| import { CopilotSession } from "./session.js"; | ||
| import type { ConnectionState, CopilotClientOptions, GetAuthStatusResponse, GetStatusResponse, ModelInfo, ResumeSessionConfig, SessionConfig, SessionLifecycleEventType, SessionLifecycleHandler, SessionListFilter, SessionMetadata, TypedSessionLifecycleHandler } from "./types.js"; | ||
| /** | ||
| * 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({ cliUrl: "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(); | ||
| * ``` | ||
| */ | ||
| export declare class CopilotClient { | ||
@@ -16,2 +49,3 @@ private cliProcess; | ||
| private forceStopping; | ||
| private onListModels?; | ||
| private modelsCache; | ||
@@ -222,6 +256,9 @@ private modelsCacheLock; | ||
| * | ||
| * 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 authenticated | ||
| * @throws Error if not connected (when no custom handler is set) | ||
| */ | ||
@@ -228,0 +265,0 @@ listModels(): Promise<ModelInfo[]>; |
+20
-8
@@ -49,2 +49,3 @@ import { spawn } from "node:child_process"; | ||
| forceStopping = false; | ||
| onListModels; | ||
| modelsCache = null; | ||
@@ -115,2 +116,3 @@ modelsCacheLock = Promise.resolve(); | ||
| } | ||
| this.onListModels = options.onListModels; | ||
| this.options = { | ||
@@ -406,2 +408,3 @@ cliPath: options.cliPath || getBundledCliPath(), | ||
| customAgents: config.customAgents, | ||
| agent: config.agent, | ||
| configDir: config.configDir, | ||
@@ -486,2 +489,3 @@ skillDirectories: config.skillDirectories, | ||
| customAgents: config.customAgents, | ||
| agent: config.agent, | ||
| skillDirectories: config.skillDirectories, | ||
@@ -563,11 +567,11 @@ disabledSkills: config.disabledSkills, | ||
| * | ||
| * 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 authenticated | ||
| * @throws Error if not connected (when no custom handler is set) | ||
| */ | ||
| async listModels() { | ||
| if (!this.connection) { | ||
| throw new Error("Client not connected"); | ||
| } | ||
| await this.modelsCacheLock; | ||
@@ -582,6 +586,14 @@ let resolveLock; | ||
| } | ||
| const result = await this.connection.sendRequest("models.list", {}); | ||
| const response = result; | ||
| const models = response.models; | ||
| this.modelsCache = models; | ||
| let models; | ||
| if (this.onListModels) { | ||
| models = await this.onListModels(); | ||
| } else { | ||
| if (!this.connection) { | ||
| throw new Error("Client not connected"); | ||
| } | ||
| const result = await this.connection.sendRequest("models.list", {}); | ||
| const response = result; | ||
| models = response.models; | ||
| } | ||
| this.modelsCache = [...models]; | ||
| return [...models]; | ||
@@ -588,0 +600,0 @@ } finally { |
+20
-2
@@ -1,2 +0,20 @@ | ||
| import { CopilotClient } from "./client.js"; | ||
| export declare const extension: CopilotClient; | ||
| import type { CopilotSession } from "./session.js"; | ||
| import type { ResumeSessionConfig } from "./types.js"; | ||
| /** | ||
| * Joins the current foreground session. | ||
| * | ||
| * @param config - Configuration to add to the session | ||
| * @returns A promise that resolves with the joined session | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { approveAll } from "@github/copilot-sdk"; | ||
| * import { joinSession } from "@github/copilot-sdk/extension"; | ||
| * | ||
| * const session = await joinSession({ | ||
| * onPermissionRequest: approveAll, | ||
| * tools: [myTool], | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export declare function joinSession(config: ResumeSessionConfig): Promise<CopilotSession>; |
+14
-2
| import { CopilotClient } from "./client.js"; | ||
| const extension = new CopilotClient({ isChildProcess: true }); | ||
| async function joinSession(config) { | ||
| const sessionId = process.env.SESSION_ID; | ||
| if (!sessionId) { | ||
| throw new Error( | ||
| "joinSession() is intended for extensions running as child processes of the Copilot CLI." | ||
| ); | ||
| } | ||
| const client = new CopilotClient({ isChildProcess: true }); | ||
| return client.resumeSession(sessionId, { | ||
| ...config, | ||
| disableResume: config.disableResume ?? true | ||
| }); | ||
| } | ||
| export { | ||
| extension | ||
| joinSession | ||
| }; |
@@ -165,2 +165,3 @@ /** | ||
| modelId: string; | ||
| reasoningEffort?: "low" | "medium" | "high" | "xhigh"; | ||
| } | ||
@@ -446,2 +447,26 @@ export interface SessionModeGetResult { | ||
| } | ||
| export interface SessionLogResult { | ||
| /** | ||
| * The unique identifier of the emitted session event | ||
| */ | ||
| eventId: string; | ||
| } | ||
| export interface SessionLogParams { | ||
| /** | ||
| * Target session identifier | ||
| */ | ||
| sessionId: string; | ||
| /** | ||
| * Human-readable message | ||
| */ | ||
| message: string; | ||
| /** | ||
| * Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". | ||
| */ | ||
| level?: "info" | "warning" | "error"; | ||
| /** | ||
| * When true, the message is transient and not persisted to the session event log on disk | ||
| */ | ||
| ephemeral?: boolean; | ||
| } | ||
| /** Create typed server-scoped RPC methods (no session required). */ | ||
@@ -498,2 +523,3 @@ export declare function createServerRpc(connection: MessageConnection): { | ||
| }; | ||
| log: (params: Omit<SessionLogParams, "sessionId">) => Promise<SessionLogResult>; | ||
| }; |
@@ -52,3 +52,4 @@ function createServerRpc(connection) { | ||
| handlePendingPermissionRequest: async (params) => connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }) | ||
| } | ||
| }, | ||
| log: async (params) => connection.sendRequest("session.log", { sessionId, ...params }) | ||
| }; | ||
@@ -55,0 +56,0 @@ } |
+20
-0
@@ -342,2 +342,22 @@ /** | ||
| setModel(model: string): Promise<void>; | ||
| /** | ||
| * Log a message to the session timeline. | ||
| * The message appears in the session event stream and is visible to SDK consumers | ||
| * and (for non-ephemeral messages) persisted to the session event log on disk. | ||
| * | ||
| * @param message - Human-readable message text | ||
| * @param options - Optional log level and ephemeral flag | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await session.log("Processing started"); | ||
| * await session.log("Disk usage high", { level: "warning" }); | ||
| * await session.log("Connection failed", { level: "error" }); | ||
| * await session.log("Debug info", { ephemeral: true }); | ||
| * ``` | ||
| */ | ||
| log(message: string, options?: { | ||
| level?: "info" | "warning" | "error"; | ||
| ephemeral?: boolean; | ||
| }): Promise<void>; | ||
| } |
+19
-0
@@ -504,2 +504,21 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node"; | ||
| } | ||
| /** | ||
| * Log a message to the session timeline. | ||
| * The message appears in the session event stream and is visible to SDK consumers | ||
| * and (for non-ephemeral messages) persisted to the session event log on disk. | ||
| * | ||
| * @param message - Human-readable message text | ||
| * @param options - Optional log level and ephemeral flag | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await session.log("Processing started"); | ||
| * await session.log("Disk usage high", { level: "warning" }); | ||
| * await session.log("Connection failed", { level: "error" }); | ||
| * await session.log("Debug info", { ephemeral: true }); | ||
| * ``` | ||
| */ | ||
| async log(message, options) { | ||
| await this.rpc.log({ message, ...options }); | ||
| } | ||
| } | ||
@@ -506,0 +525,0 @@ export { |
+14
-1
@@ -80,2 +80,9 @@ /** | ||
| useLoggedInUser?: boolean; | ||
| /** | ||
| * Custom handler for listing available models. | ||
| * When provided, client.listModels() calls this handler instead of | ||
| * querying the CLI server. Useful in BYOK mode to return models | ||
| * available from your custom provider. | ||
| */ | ||
| onListModels?: () => Promise<ModelInfo[]> | ModelInfo[]; | ||
| } | ||
@@ -591,2 +598,8 @@ /** | ||
| /** | ||
| * Name of the custom agent to activate when the session starts. | ||
| * Must match the `name` of one of the agents in `customAgents`. | ||
| * Equivalent to calling `session.rpc.agent.select({ name })` after creation. | ||
| */ | ||
| agent?: string; | ||
| /** | ||
| * Directories to load skills from. | ||
@@ -609,3 +622,3 @@ */ | ||
| */ | ||
| export type ResumeSessionConfig = Pick<SessionConfig, "clientName" | "model" | "tools" | "systemMessage" | "availableTools" | "excludedTools" | "provider" | "streaming" | "reasoningEffort" | "onPermissionRequest" | "onUserInputRequest" | "hooks" | "workingDirectory" | "configDir" | "mcpServers" | "customAgents" | "skillDirectories" | "disabledSkills" | "infiniteSessions"> & { | ||
| export type ResumeSessionConfig = Pick<SessionConfig, "clientName" | "model" | "tools" | "systemMessage" | "availableTools" | "excludedTools" | "provider" | "streaming" | "reasoningEffort" | "onPermissionRequest" | "onUserInputRequest" | "hooks" | "workingDirectory" | "configDir" | "mcpServers" | "customAgents" | "agent" | "skillDirectories" | "disabledSkills" | "infiniteSessions"> & { | ||
| /** | ||
@@ -612,0 +625,0 @@ * When true, skips emitting the session.resume event. |
+2
-1
@@ -7,3 +7,3 @@ { | ||
| }, | ||
| "version": "0.1.32", | ||
| "version": "0.1.33-unstable.0", | ||
| "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", | ||
@@ -74,4 +74,5 @@ "main": "./dist/index.js", | ||
| "dist/**/*", | ||
| "docs/**/*", | ||
| "README.md" | ||
| ] | ||
| } |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
268688
17.44%21
16.67%6753
3.59%3
50%