使用 @github/copilot-sdk 编写 Copilot CLI 扩展:工具、Hook 与会话事件实战指南
【免费下载链接】copilot-sdkMulti-platform SDK for integrating GitHub Copilot Agent into apps and services项目地址: https://gitcode.com/GitHub_Trending/co/copilot-sdk
本文是一份面向 Node.js 开发者的 Copilot CLI 扩展实战指南,以
@github/copilot-sdk的扩展 API(joinSession)为主线,完整讲解扩展骨架、自定义工具注册、生命周期 Hook、会话事件订阅、程序化消息发送以及权限/用户输入处理器,并给出可直接运行的完整示例。读完本文,你将能够基于 nodejs/docs/examples.md 的场景,独立编写出能向 CLI 时间线输出日志、拦截危险命令、注入额外上下文、监听文件变更并响应 agent 提问的生产级扩展。
前置准备:扩展的运行环境
在动手之前,先明确扩展的定位。Copilot CLI 扩展是一段运行在独立 Node.js 子进程中的代码,通过 JSON-RPC 与 CLI 主进程经 stdio 通信。CLI 负责发现并孵化扩展进程,扩展则通过 SDK 注册工具、注册 Hook、监听事件(详见 扩展工作机制)。
扩展的入口文件结构约定如下:
.github/extensions/ my-extension/ extension.mjs ← 入口文件(必需,且必须是 .mjs)- 仅支持
.mjs(ES Module),文件名必须为extension.mjs; - 每个扩展独占一个子目录;
@github/copilot-sdk的导入由 CLI 自动解析,无需自行安装。
SDK 对 Node.js 版本的要求为^20.19.0或>=22.12.0(见 nodejs/README.md)。
扩展骨架:一切从 joinSession 开始
每个扩展的起点都是同一段样板代码——调用扩展 API 的入口joinSession:
import { joinSession } from "@github/copilot-sdk/extension"; const session = await joinSession({ hooks: { /* ... */ }, tools: [ /* ... */ ], });joinSession返回一个CopilotSession对象,你可以用它发送消息、订阅事件、向时间线写日志。
从源码看,joinSession的实现位于 nodejs/src/extension.ts,它有几个值得注意的底层行为:
- 它读取环境变量
SESSION_ID,若不存在则直接抛出错误(提示该 API 仅用于作为 Copilot CLI 子进程运行的扩展); - 它通过
_internalConnection: { kind: "parent-process" }建立与父进程 CLI 的连接,并调用client.resumeSessionForExtension(...)挂接到用户当前的前台会话; - 默认权限处理器为
defaultJoinSessionPermissionHandler,会话恢复事件默认被抑制(suppressResumeEvent: true)。
如果你需要访问被 CLI 剥离的敏感环境变量(如GITHUB_TOKEN),可以在配置中声明requestedEnvironmentVariables: ["GITHUB_TOKEN"]。CLI 会向用户展示扩展名与请求的变量清单,批准后这些值会在joinSessionresolve 前写入process.env;拒绝则joinSessionreject,扩展不会加载,其工具永远不会到达模型。
平台差异(Windows vs macOS/Linux)
扩展很可能需要调用外部命令,不同平台差异显著:
- 用
process.platform === "win32"在运行时检测 Windows; - 剪贴板命令:macOS 用
pbcopy,Windows 用clip; - 在 Windows 上执行
code、npx、npm等.cmd脚本时,用exec()而不是execFile(); - PowerShell 的 stderr 重定向用
*>&1而不是2>&1。
向 Timeline 输出日志
使用session.log()可以在 CLI 时间线中向用户展示消息:
const session = await joinSession({ 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: [], });日志级别支持"info"(默认)、"warning"、"error"。设置ephemeral: true表示临时消息,不会被持久化。从源码看,session.log()最终调用的是底层 RPCthis.rpc.log({ message, ...options })(见 nodejs/src/session.ts)。
注册自定义工具
工具是 agent 可以调用的函数。每个工具需要名称、描述、JSON Schema 参数以及处理函数(handler)。
基础工具
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}`; }, }, ];调用外部 shell 命令的工具
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); }); }); }, }调用外部 API 的工具
{ 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(); }, }工具处理函数的调用上下文
handler 的第二个参数携带本次调用的元数据:
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"; };toolCallId在后续"区分 agent 编辑与用户编辑"的场景中非常关键,可以用它把tool.execution_start与tool.execution_complete事件关联起来。
Hooks:在关键生命周期点拦截与改写
Hook 在关键生命周期点拦截并修改行为,全部注册在hooks选项中。SDK 类型层面定义的SessionHooks接口(见 nodejs/src/types.ts)除文档表格中的成员外,还包含onPreMcpToolCall(MCP 工具调用前)、onUserPromptTransformed(运行时转换用户提示后、写入历史前)、onAgentStop(顶层 agent 自然停止时,可返回{ decision: "block", reason }让 agent 继续执行)。
可用 Hooks 一览
| Hook | 触发时机 | 可修改内容 |
|---|---|---|
onUserPromptSubmitted | 用户发送消息 | 提示文本、追加上下文 |
onPreToolUse | 工具执行前 | 工具参数、权限决策、追加上下文 |
onPostToolUse | 工具成功执行后 | 工具结果、追加上下文 |
onPostToolUseFailure | 工具执行返回失败后 | 向模型追加隐藏指引 |
onSessionStart | 会话开始或恢复 | 追加上下文 |
onSessionEnd | 会话结束 | 清理动作、摘要 |
onErrorOccurred | 发生错误时 | 错误处理策略(retry/skip/abort) |
所有 hook 的输入都包含timestamp(Date类型)和workingDirectory。源码中的BaseHookInput(见 nodejs/src/types.ts)还额外提供sessionId字段。
改写用户消息
在 agent 看到用户输入前,用onUserPromptSubmitted重写或增强:
hooks: { onUserPromptSubmitted: async (input) => { // Rewrite the prompt return { modifiedPrompt: input.prompt.toUpperCase() }; }, }向每条消息注入额外上下文
返回additionalContext可以静默追加 agent 会遵循的指令:
hooks: { onUserPromptSubmitted: async (input) => { return { additionalContext: "Always respond in bullet points. Follow our team coding standards.", }; }, }类型定义中,UserPromptSubmittedHookOutput除modifiedPrompt、additionalContext外还支持suppressOutput(见 nodejs/src/types.ts)。
基于关键字发送跟进消息
用session.send()程序化注入一条新的用户消息:
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); } }, }提示:如果跟进消息可能再次触发同一个 hook,务必做好防护,避免无限循环。
阻止危险的工具调用
用onPreToolUse检查并可选地拒绝工具执行。返回值支持permissionDecision: "allow" | "deny" | "ask",并可用permissionDecisionReason向模型说明原因(见 nodejs/src/types.ts):
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" }; }, }在工具执行前修改参数
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}`, }, }; } }, }注意 Windows 下 stderr 重定向要用 PowerShell 的*>&1语法——这正是扩展开发中"小平台差异大影响"的典型例子。
响应 agent 创建或编辑文件
onPostToolUse在工具成功完成后触发,可执行副作用(如用 VS Code 打开文件):
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}"`, () => {}); } } }, }响应工具失败
onPostToolUse只对成功的工具执行触发。要观察或响应失败,注册onPostToolUseFailure。其输入包含input.error(字符串化的失败信息);返回值中只有additionalContext会被运行时消费,并作为隐藏指引与失败的工具结果一起追加给模型(对应类型 nodejs/src/types.ts):
hooks: { onPostToolUseFailure: async (input) => { if (input.toolName === "bash") { return { additionalContext: "The command failed. Try a different approach.", }; } }, }从源码注释还可以了解到:"rejected"、"denied"、"timeout"等结果目前同样不会触发此 hook——只有"failure"会。
每次文件编辑后运行 linter
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}` }; } } }, }带重试逻辑的错误处理
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}`, }; }, }类型层面,ErrorOccurredHookInput的errorContext取值包括"model_call" | "tool_execution" | "system" | "user_input",ErrorOccurredHookOutput支持errorHandling: "retry" | "skip" | "abort"、retryCount、userNotification与suppressOutput(见 nodejs/src/types.ts)。
会话生命周期 Hook
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" }, }SessionStartHookInput还包含可选的initialPrompt,SessionEndHookInput提供finalMessage与error,返回值支持cleanupActions与sessionSummary(见 nodejs/src/types.ts)。
会话事件:实时响应 agent 的动态
调用joinSession之后,用session.on()实时响应事件。
监听特定事件类型
session.on("assistant.message", (event) => { // event.data.content has the agent's response text });监听全部事件
session.on((event) => { // event.type and event.data are available for all events });取消订阅
session.on()返回退订函数:
const unsubscribe = session.on("tool.execution_complete", (event) => { // event.data.success, event.data.result, event.data.error }); // Later, stop listening unsubscribe();示例:自动把 agent 回复复制到剪贴板
结合一个 hook(检测关键字)与一个会话事件(捕获回复):
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({ 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); } });最常用的 10 种事件类型
| 事件类型 | 描述 | 关键数据字段 |
|---|---|---|
assistant.message | agent 的最终回复 | content,messageId,toolRequests |
assistant.message_delta | 消息内容分块(临时) | deltaContent |
tool.execution_start | 工具即将执行 | toolCallId,toolName,arguments |
tool.execution_complete | 工具执行完成 | toolCallId,success,result,error |
user.message | 用户发送了消息 | content,attachments,source |
session.idle | 会话完成一轮处理 | aborted |
session.error | 发生错误 | errorType,message,stack |
permission.requested | agent 需要权限(shell、文件写入等) | requestId,permissionRequest.kind |
session.shutdown | 会话即将结束 | shutdownType,totalPremiumRequests,codeChanges |
assistant.turn_start | agent 开始新的思考/响应周期 | turnId |
示例:检测 plan 文件被创建或编辑
用session.workspacePath定位会话的plan.md(启用 infinite sessions 时,工作区目录通常形如~/.copilot/session-state/<id>),再配合fs.watchFile检测变化。用toolCallId关联tool.execution_start/tool.execution_complete事件,以区分 agent 编辑与用户编辑:
import { existsSync, watchFile, readFileSync } from "node:fs"; import { join } from "node:path"; 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(); 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.`, }); } }); }这里把workspacePath的用途体现得很直观:CopilotSession.workspacePath仅在启用 infinite sessions 时存在,指向包含checkpoints/、plan.md、files/子目录的工作区(对应 nodejs/src/session.ts 的 getter)。
示例:响应用户在仓库中手动编辑文件
对process.cwd()使用fs.watch的recursive: true检测文件变更,并通过跟踪tool.execution_start/tool.execution_complete事件过滤掉 agent 自身的编辑:
import { watch, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { joinSession } from "@github/copilot-sdk/extension"; const agentEditPaths = new Set(); const session = await joinSession(); 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)); });程序化发送消息
即发即弃(Fire-and-forget)
await session.send({ prompt: "Analyze the test results." });发送并等待回复
const response = await session.sendAndWait({ prompt: "What is 2 + 2?" }); // response?.data.content contains the agent's reply带文件附件发送
await session.send({ prompt: "Review this file", attachments: [{ type: "file", path: "./src/index.ts" }], });session.send()返回消息 ID,选项还支持source("user"、"system"或agent-<id>溯源)与mode("enqueue"/"immediate"投递模式)等字段;sendAndWait()额外接受毫秒级timeout,返回最终的 assistant 消息事件,未收到时返回undefined(详见 nodejs/README.md 的CopilotSession章节)。
权限与用户输入处理器
自定义权限逻辑
通过onPermissionRequest注入自定义审批逻辑。该选项也可以用于client.createSession(SDK 主入口)而非仅仅扩展场景:
const session = await joinSession({ onPermissionRequest: async (request) => { if (request.kind === "shell") { // request.fullCommandText has the shell command return { kind: "approve-once" }; } if (request.kind === "write") { return { kind: "approve-once" }; } return { kind: "reject" }; }, });request.kind用于区分操作类型("shell"、"write"、"read"、"mcp"、"custom-tool"、"url"、"memory"、"hook"等)。审批结果支持"approve-once"、"approve-for-session"、"approve-for-location"等作用域(详见 nodejs/README.md 的 Permission Handling 章节)。
处理 agent 提问(ask_user)
注册onUserInputRequest以启用 agent 的ask_user工具:
const session = await joinSession({ onUserInputRequest: async (request) => { // request.question has the agent's question // request.choices has the options (if multiple choice) return { answer: "yes", wasFreeform: false }; }, });完整示例:多特性扩展
以下扩展把工具、Hook 与事件三者结合:遇到 "copy this" 关键字时自动复制下一条回复;为每条消息注入团队规范;拦截危险 shell 命令;文件创建/编辑后自动在编辑器中打开;并提供一个copy_to_clipboard自定义工具:
import { execFile, exec } from "node:child_process"; 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({ 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", permissionDecisionReason: "Destructive commands are not allowed.", }; } } }, 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.result });延伸阅读
- 扩展工作机制与文件结构:Discovery、Launch、Connection、Registration、Lifecycle 全流程,以及
requestedEnvironmentVariables的敏感变量授权机制; - Node.js SDK 完整 API 参考:
CopilotClient/CopilotSession、事件类型、流式输出、自定义 Provider、系统消息定制等; - Agent Factories 扩展:authoring、running、resuming、observing Agent Factories;
- Agent 程序化编写扩展:面向 agent 的分步工作流;
- 交互式 Chat 示例:使用
CopilotClient主 SDK API 的完整可运行聊天程序; - Hook 类型定义:
SessionHooks接口与全部 Hook 输入/输出类型的权威定义; - joinSession 实现:扩展入口的实际连接逻辑。
【免费下载链接】copilot-sdkMulti-platform SDK for integrating GitHub Copilot Agent into apps and services项目地址: https://gitcode.com/GitHub_Trending/co/copilot-sdk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考