news 2026/9/15 18:45:20

使用 @github/copilot-sdk 编写 Copilot CLI 扩展:工具、Hook 与会话事件实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
使用 @github/copilot-sdk 编写 Copilot CLI 扩展:工具、Hook 与会话事件实战指南

使用 @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 上执行codenpxnpm.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_starttool.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 的输入都包含timestampDate类型)和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.", }; }, }

类型定义中,UserPromptSubmittedHookOutputmodifiedPromptadditionalContext外还支持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}`, }; }, }

类型层面,ErrorOccurredHookInputerrorContext取值包括"model_call" | "tool_execution" | "system" | "user_input"ErrorOccurredHookOutput支持errorHandling: "retry" | "skip" | "abort"retryCountuserNotificationsuppressOutput(见 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还包含可选的initialPromptSessionEndHookInput提供finalMessageerror,返回值支持cleanupActionssessionSummary(见 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.messageagent 的最终回复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.requestedagent 需要权限(shell、文件写入等)requestId,permissionRequest.kind
session.shutdown会话即将结束shutdownType,totalPremiumRequests,codeChanges
assistant.turn_startagent 开始新的思考/响应周期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.mdfiles/子目录的工作区(对应 nodejs/src/session.ts 的 getter)。

示例:响应用户在仓库中手动编辑文件

process.cwd()使用fs.watchrecursive: 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 18:44:40

Python+tushare财务指标选股实战:从数据对接到策略筛选

简介&#xff1a;一个基于Python和Tushare的财务指标选股实战包&#xff0c;专为金融从业者、量化投资爱好者以及有Python基础的数据分析人员设计&#xff0c;用于解决从A股市场批量提取财务数据、计算关键财务指标并筛选潜力股票的问题。资源覆盖股票列表获取、财务报告读取、…

作者头像 李华
网站建设 2026/9/15 18:44:14

fNIRS公开数据集实用指南:格式、处理与避坑要点

做fNIRS研究这两年&#xff0c;我有个很深的感触&#xff1a;想找一套靠谱的公开数据集&#xff0c;难度一点也不比去实验室重新采数据低。fNIRS&#xff08;功能性近红外光谱&#xff09;在国内实验室的普及度涨得很快&#xff0c;但它的公开数据生态相比fMRI和EEG要碎片化得多…

作者头像 李华
网站建设 2026/9/15 18:41:54

在 Nitro 中集成 Elysia:使用 Server Entry 构建完整 HTTP 服务

在 Nitro 中集成 Elysia&#xff1a;使用 Server Entry 构建完整 HTTP 服务 【免费下载链接】nitro Next Generation Server Toolkit. Create web servers with everything you need and deploy them wherever you prefer. 项目地址: https://gitcode.com/GitHub_Trending/ni…

作者头像 李华
网站建设 2026/9/15 18:41:13

Discourse开源论坛实战:从部署到运营的完整指南

干了这么多年社区搭建和开源项目落地&#xff0c;我接触过不少论坛系统&#xff0c;从老牌的 phpBB、Discuz&#xff0c;到后起之秀 NodeBB、Flarum&#xff0c;都折腾过不止一遍。但真正让我觉得“这玩意儿配得上时代”的&#xff0c;还是 Discourse 这套开源论坛方案。很多人…

作者头像 李华