Skip to main content
Skip to content

使用挂钩

挂钩使你能够将自定义逻辑插入到Copilot会话的每个阶段,从启动的那一刻起,到每次用户提示和工具调用,到它结束的那一刻。 本指南将演练实际用例,以便在不修改核心代理行为的情况下交付权限、审核、通知等。

概述

挂钩是创建会话时注册一次的回调。 SDK 在会话生命周期中定义完善的点调用它,传递上下文输入,并选择性地接受修改会话行为的输出。

图示:显示所述过程的流程图。

挂钩当它触发时你能做什么
会话生命周期挂钩会话开始(新建会话或恢复会话)注入上下文,加载首选项
用户提示提交挂钩用户发送消息重写提示,添加上下文,筛选输入
用户提示转换钩子运行时生成模型提示检查或替换面向模型的内容
工具使用前挂钩在工具执行之前允许/拒绝/修改调用
工具使用后挂钩工具返回后(仅在成功时)转换结果,屏蔽机密,审核
工具使用后挂钩工具返回失败结果后添加重试指引,记录失败日志
会话生命周期挂钩会话结束清理、记录度量指标
错误处理挂钩已引发错误自定义日志记录、重试逻辑、警报

所有挂钩都是可选的,您只需注册所需要的挂钩。 从任何挂钩中返回 null(或该语言的等效表达)会告知 SDK 继续执行默认行为。

仅当工作区受信任后,才会加载基于文件的存储库挂钩和存储库挂钩设置。 在授予工作区信任之前,用户钩子将保持可用。

来自其他目录的存储库挂钩

存储库文件系统挂钩独立于本指南中所述的 SDK 回调。 若要在本地会话创建或冷恢复期间从其他目录加载它们,请提供 additionalDirectories;CLI --add-dir 默认也参与。

DISABLE_ADDITIONAL_DIRECTORY_HOOKS=true 恢复仅主作用域;enableFileHooks 可以通过 session.rpc.options.update 启用或禁用存储库挂钩;显式 SDK 挂钩目录和回调是独立的。

发现功能会使用每个已接受目录所在的 Git 根目录,或者在目录位于 Git 存储库之外时使用目录本身。 例如,在存储库 B 中提供 B\packages\core 即可使用 B\.github\hooks 和 B 的存储库钩子设置,而不仅仅是 B\packages\core 内部的配置。

警告

仅提供受信任的启动目录。 这会授权来自所有符合条件的启动目录的挂钩命令和挂钩提供的提示,而无需对每个目录或每个挂钩进行额外审批。 以后的文件访问授予不会授权钩子。 现有的托管策略、挂钩禁用控件和沙盒规则仍适用;它们不会使恶意挂钩内容安全。

后续的目录授权不会添加钩子源。 冷启动恢复必须重新填充目录;以后的授予或应急出口更新无法更改保留的授权。

注册挂钩

回调 hookDirectories 与文件支持的 hooks 保持分离。 有关打包挂钩配置和相邻资源,请参阅 自定义发现位置。

代理级声明式钩子

自定义代理可以在 .agent.md frontmatter 或 SDK 的自定义代理配置中声明 hooks 事件映射。 这些是声明性命令或 HTTP 操作,而不是 SDK 回调函数。 它们仅在选择该代理时运行,以及适用的会话、用户、存储库、插件和托管策略挂钩。 更改或清除所选代理会更改下一个调用的作用域钩子,而无需更改其他会话或并发子代理。

---
name: formatter
description: Formats changed files
hooks:
  PostToolUse:
    - type: command
      command: ./scripts/format-changed-files.sh
      windows: ./scripts/format-changed-files.ps1
---
Format the files you change.

支持使用运行时事件名称(例如 postToolUse)和支持的 VS Code 别名(例如 PostToolUse)。 command 提供回退 shell 命令;windows、linux 和 osx 会在各自对应的平台上覆盖该命令。 挂钩命令相对于执行会话的存储库根目录运行,除非它们设置了显式的工作目录。 挂钩负载和决策遵循运行时的现有挂钩协定。

当本地代理作为子代理运行时,其作用域 Stop(agentStop)操作在 SubagentStop 运行,而不是在普通轮次停止时运行。 该代理上的显式 SubagentStop 操作仍会观察其自己的子级。 停止回调会在先前的块之后收到 stop_hook_active: true。 在连续 8 个作用域 Stop 延续之后,运行时将忽略进一步的作用域块并接受子级的最终响应,除非父生命周期挂钩仍会阻止完成。

存储库和继承的代理钩子需要工作区准入。 代理发现或选择本身不会授予信任。 现有的挂钩禁用设置、企业限制、沙盒权限和超时也适用于作用域内操作。 SDK 提供的代理程序无法声明受管插件特权。 远程或组织托管的代理定义不执行作用域内挂钩,且仅本地运行时子代理支持作用域限定的 Stop 分派,不支持外部 AHP 参与者。

在选择事件之前报告不支持的事件名称。 操作准备错误仅影响其声明的事件;共享和托管策略钩子仍在运行。

创建或恢复会话时传递一个hooks对象。 下面的每个示例都遵循此模式。

代码语言 navigation

TypeScript
import { CopilotClient } from "@github/copilot-sdk";

const client = new CopilotClient();
await client.start();

const session = await client.createSession({
  hooks: {
    onSessionStart: async (input, invocation) => {
      /* ... */
    },
    onPreToolUse: async (input, invocation) => {
      /* ... */
    },
    onPostToolUse: async (input, invocation) => {
      /* ... */
    },
    // ... add only the hooks you need
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

提示

每个挂钩处理程序都会接收一个包含 invocation 的 sessionId 参数,这对关联日志和维护每会话状态很有用。

用例:权限控制

用于 onPreToolUse 生成一个权限层,该层决定代理可以运行哪些工具、允许哪些参数,以及是否应在执行前提示用户。

将一组安全的工具加入允许列表

代码语言 navigation

TypeScript
const READ_ONLY_TOOLS = ["read_file", "glob", "grep", "view"];

const session = await client.createSession({
  hooks: {
    onPreToolUse: async (input) => {
      if (!READ_ONLY_TOOLS.includes(input.toolName)) {
        return {
          permissionDecision: "deny",
          permissionDecisionReason: `Only read-only tools are allowed. "${input.toolName}" was blocked.`,
        };
      }
      return { permissionDecision: "allow" };
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

限制对特定目录的文件访问

const ALLOWED_DIRS = ["/home/user/projects", "/tmp"];

const session = await client.createSession({
  hooks: {
    onPreToolUse: async (input) => {
      if (["read_file", "write_file", "edit"].includes(input.toolName)) {
        const filePath = (input.toolArgs as { path: string }).path;
        const allowed = ALLOWED_DIRS.some((dir) => filePath.startsWith(dir));

        if (!allowed) {
          return {
            permissionDecision: "deny",
            permissionDecisionReason: `Access to "${filePath}" is outside the allowed directories.`,
          };
        }
      }
      return { permissionDecision: "allow" };
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

在破坏性操作之前询问用户

const DESTRUCTIVE_TOOLS = ["delete_file", "shell", "bash"];

const session = await client.createSession({
  hooks: {
    onPreToolUse: async (input) => {
      if (DESTRUCTIVE_TOOLS.includes(input.toolName)) {
        return { permissionDecision: "ask" };
      }
      return { permissionDecision: "allow" };
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

如果返回 "ask",会在运行时将决策委托给用户,这对于需要人工干预的破坏性操作非常有用。

用例:审核和符合性

结合 onPreToolUse、onPostToolUse 和会话生命周期挂钩,构建一个完整的审核线索,以记录代理所执行的每个操作。

结构化审核日志

代码语言 navigation

TypeScript
interface AuditEntry {
  timestamp: Date;
  sessionId: string;
  event: string;
  toolName?: string;
  toolArgs?: unknown;
  toolResult?: unknown;
  prompt?: string;
}

const auditLog: AuditEntry[] = [];

const session = await client.createSession({
  hooks: {
    onSessionStart: async (input, invocation) => {
      auditLog.push({
        timestamp: input.timestamp,
        sessionId: invocation.sessionId,
        event: "session_start",
      });
      return null;
    },
    onUserPromptSubmitted: async (input, invocation) => {
      auditLog.push({
        timestamp: input.timestamp,
        sessionId: invocation.sessionId,
        event: "user_prompt",
        prompt: input.prompt,
      });
      return null;
    },
    onPreToolUse: async (input, invocation) => {
      auditLog.push({
        timestamp: input.timestamp,
        sessionId: invocation.sessionId,
        event: "tool_call",
        toolName: input.toolName,
        toolArgs: input.toolArgs,
      });
      return { permissionDecision: "allow" };
    },
    onPostToolUse: async (input, invocation) => {
      auditLog.push({
        timestamp: input.timestamp,
        sessionId: invocation.sessionId,
        event: "tool_result",
        toolName: input.toolName,
        toolResult: input.toolResult,
      });
      return null;
    },
    onSessionEnd: async (input, invocation) => {
      auditLog.push({
        timestamp: input.timestamp,
        sessionId: invocation.sessionId,
        event: "session_end",
      });

      // Persist the log — swap this with your own storage backend
      await fs.promises.writeFile(
        `audit-${invocation.sessionId}.json`,
        JSON.stringify(auditLog, null, 2),
      );
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

从工具的结果中删除敏感信息

const SECRET_PATTERNS = [
  /(?:api[_-]?key|token|secret|password)\s*[:=]\s*["']?[\w\-\.]+["']?/gi,
];

const session = await client.createSession({
  hooks: {
    onPostToolUse: async (input) => {
      if (typeof input.toolResult !== "string") return null;

      let redacted = input.toolResult;
      for (const pattern of SECRET_PATTERNS) {
        redacted = redacted.replace(pattern, "[REDACTED]");
      }

      return redacted !== input.toolResult
        ? { modifiedResult: redacted }
        : null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

用例:通知和声音

挂钩在应用程序的进程中触发,因此可能会引起任何副作用,如桌面通知、声音、Slack 消息或 Webhook 调用。

会话事件的桌面通知

代码语言 navigation

TypeScript
import notifier from "node-notifier"; // npm install node-notifier

const session = await client.createSession({
  hooks: {
    onSessionEnd: async (input, invocation) => {
      notifier.notify({
        title: "Copilot Session Complete",
        message: `Session ${invocation.sessionId.slice(0, 8)} finished (${input.reason}).`,
      });
      return null;
    },
    onErrorOccurred: async (input) => {
      notifier.notify({
        title: "Copilot Error",
        message: input.error.slice(0, 200),
      });
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

工具完成后播放声音

import { exec } from "node:child_process";

const session = await client.createSession({
  hooks: {
    onPostToolUse: async (input) => {
      // macOS: play a system sound after every tool call
      exec("afplay /System/Library/Sounds/Pop.aiff");
      return null;
    },
    onErrorOccurred: async () => {
      exec("afplay /System/Library/Sounds/Basso.aiff");
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

出现错误时发布到 Slack

const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!;

const session = await client.createSession({
  hooks: {
    onErrorOccurred: async (input, invocation) => {
      if (!input.recoverable) {
        await fetch(SLACK_WEBHOOK_URL, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            text: `🚨 Unrecoverable error in session \`${invocation.sessionId.slice(0, 8)}\`:\n\`\`\`${input.error}\`\`\``,
          }),
        });
      }
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

用例:提示扩充

使用 onSessionStart 和 onUserPromptSubmitted 自动注入上下文,以便用户不必重复自己。

在会话开始时注入项目元数据

const session = await client.createSession({
  hooks: {
    onSessionStart: async (input) => {
      const pkg = JSON.parse(
        await fs.promises.readFile("package.json", "utf-8"),
      );
      return {
        additionalContext: [
          `Project: ${pkg.name} v${pkg.version}`,
          `Node: ${process.version}`,
          `Working directory: ${input.workingDirectory}`,
        ].join("\n"),
      };
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

在提示中展开简短命令

const SHORTCUTS: Record<string, string> = {
  "/fix": "Find and fix all errors in the current file",
  "/test": "Write comprehensive unit tests for this code",
  "/explain": "Explain this code in detail",
  "/refactor": "Refactor this code to improve readability",
};

const session = await client.createSession({
  hooks: {
    onUserPromptSubmitted: async (input) => {
      for (const [shortcut, expansion] of Object.entries(SHORTCUTS)) {
        if (input.prompt.startsWith(shortcut)) {
          const rest = input.prompt.slice(shortcut.length).trim();
          return { modifiedPrompt: rest ? `${expansion}: ${rest}` : expansion };
        }
      }
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

用例:错误处理和恢复

通过使用 onErrorOccurred 挂钩,你可以对失败做出反应,无论你是要重试、通知人员还是正常关闭。

重试暂时性模型错误

const session = await client.createSession({
  hooks: {
    onErrorOccurred: async (input) => {
      if (input.errorContext === "model_call" && input.recoverable) {
        return {
          errorHandling: "retry",
          retryCount: 3,
          userNotification: "Temporary model issue — retrying…",
        };
      }
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

友好的错误消息

const FRIENDLY_MESSAGES: Record<string, string> = {
  model_call: "The AI model is temporarily unavailable. Please try again.",
  tool_execution: "A tool encountered an error. Check inputs and try again.",
  system: "A system error occurred. Please try again later.",
};

const session = await client.createSession({
  hooks: {
    onErrorOccurred: async (input) => {
      return {
        userNotification: FRIENDLY_MESSAGES[input.errorContext] ?? input.error,
      };
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

用例:会话指标

跟踪会话的运行时间、调用的工具数以及会话结束的原因(对于仪表板和成本监视非常有用)。

代码语言 navigation

TypeScript
const metrics = new Map<
  string,
  { start: Date; toolCalls: number; prompts: number }
>();

const session = await client.createSession({
  hooks: {
    onSessionStart: async (input, invocation) => {
      metrics.set(invocation.sessionId, {
        start: input.timestamp,
        toolCalls: 0,
        prompts: 0,
      });
      return null;
    },
    onUserPromptSubmitted: async (_input, invocation) => {
      metrics.get(invocation.sessionId)!.prompts++;
      return null;
    },
    onPreToolUse: async (_input, invocation) => {
      metrics.get(invocation.sessionId)!.toolCalls++;
      return { permissionDecision: "allow" };
    },
    onSessionEnd: async (input, invocation) => {
      const m = metrics.get(invocation.sessionId)!;
      const durationSec =
        (input.timestamp.getTime() - m.start.getTime()) / 1000;

      console.log(
        `Session ${invocation.sessionId.slice(0, 8)}: ` +
          `${durationSec.toFixed(1)}s, ${m.prompts} prompts, ` +
          `${m.toolCalls} tool calls, ended: ${input.reason}`,
      );

      metrics.delete(invocation.sessionId);
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

组合挂钩

挂钩会自然组合。 单个 hooks 对象即可处理权限 、 审计和通知 — 每个挂钩各司其职。

const session = await client.createSession({
  hooks: {
    onSessionStart: async (input) => {
      console.log(`[audit] session started in ${input.workingDirectory}`);
      return { additionalContext: "Project uses TypeScript and Vitest." };
    },
    onPreToolUse: async (input) => {
      console.log(`[audit] tool requested: ${input.toolName}`);
      if (input.toolName === "shell") {
        return { permissionDecision: "ask" };
      }
      return { permissionDecision: "allow" };
    },
    onPostToolUse: async (input) => {
      console.log(`[audit] tool completed: ${input.toolName}`);
      return null;
    },
    onErrorOccurred: async (input) => {
      console.error(`[alert] ${input.errorContext}: ${input.error}`);
      return null;
    },
    onSessionEnd: async (input, invocation) => {
      console.log(
        `[audit] session ${invocation.sessionId.slice(0, 8)} ended: ${input.reason}`,
      );
      return null;
    },
  },
  onPermissionRequest: async () => ({ kind: "approve-once" }),
});

最佳做法

  1. 保持挂钩紧固。 每个挂钩都内联运行 - 缓慢的挂钩会延迟对话。 尽可能将大量工作(数据库写入、HTTP 调用)卸载到后台队列。

  2. 在没有任何更改时返回 null 。 这会告知 SDK 继续执行默认值,并避免不必要的对象分配。

  3. 明确权限决策。 返回 { permissionDecision: "allow" } 比返回 null 更明了,尽管两者都可以使用该工具。

  4. 不要忽略关键错误。 可以抑制可恢复的工具错误,但始终记录或针对无法恢复的工具错误发出警报。

  5. 尽可能使用 additionalContext 而不是 modifiedPrompt。 追加上下文会保留用户的原始意向,同时仍指导模型。

  6. 根据会话 ID 确定范围状态。 如果你跟踪每会话数据,请针对 invocation.sessionId 将其键入,并在 onSessionEnd 中进行清理。

Reference

有关完整类型定义、输入/输出字段表以及每个挂钩的其他示例,请参阅 API 参考:

另见