MusePi

钩子

English 中文

本文档描述 src/extensibility/hooks/* 中的当前钩子子系统代码

运行时的当前状态

默认 CLI runtime 初始化扩展运行器路径。在当前的启动流程中:

因此,本文档记录的是遗留钩子子系统的实现本身(类型/加载器/运行器/包装器),以及当发现钩子路径由扩展运行器加载时仍被接受的工厂形状。

关键文件

钩子模块是什么

钩子模块必须默认导出一个工厂:

import type { HookAPI } from "@musepi/pi-coding-agent/extensibility/hooks";

export default function hook(pi: HookAPI): void {
  pi.on("tool_call", async (event, ctx) => {
    if (
      event.toolName === "bash" &&
      String(event.input.command ?? "").includes("rm -rf")
    ) {
      return { block: true, reason: "blocked by policy" };
    }
  });
}

该工厂可以:

发现与加载

默认会话通过扩展运行器发现 JS/TS 钩子工厂。discoverExtensionPaths(configuredPaths, cwd) 执行:

  1. 从能力注册表加载原生扩展模块
  2. 从钩子能力注册表加载可导入的 .ts/.js 钩子工厂
  3. 追加插件扩展入口点
  4. 追加显式配置的路径

遗留的 discoverAndLoadHooks(configuredPaths, cwd) 辅助函数仍然存在,并执行:

  1. 从能力注册表加载已发现的钩子(loadCapability("hooks")
  2. 追加显式配置的路径(按绝对路径去重)
  3. 调用 loadHooks(allPaths, cwd)

随后 loadHooks 导入每个路径并期望一个 default 函数。

路径解析

loader.ts 将钩子路径解析为:

事件表面

钩子事件在 types.ts 中是强类型的。

会话事件

Agent/上下文事件

工具事件(模型前后)

这是钩子子系统的核心前置/后置拦截模型。

钩子工具拦截流程

tool_call 处理器
   │
   ├─ 任意 { block: true }? ── 是 ──> throw(工具被阻断)
   │
   └─ 否
      │
      ▼
   执行底层工具
      │
      ├─ 成功 ──> tool_result 处理器可以覆盖 { content, details }
      │
      └─ 错误   ──> 发射 tool_result(isError=true) 然后重新抛出原始错误

执行模型与变更语义

1)执行前:tool_call

HookToolWrapper.execute() 在工具执行前发射 tool_call

2)工具执行

如果未被阻断,底层工具正常执行。

3)执行后:tool_result

成功后,包装器发射带有以下内容的 tool_result

如果处理器返回覆盖:

工具失败时,包装器发射 isError: true 和错误文本内容的 tool_result,然后重新抛出原始错误。

钩子可以变更什么

该实现中钩子无法变更什么

排序与冲突行为

发现级排序

能力提供者按优先级排序(更高优先级的在前)。按能力键去重,先出现的获胜。

对于 hooks,能力键是 ${type}:${tool}:${name}。来自较低优先级提供者的被 shadow 的重复项被标记并从有效发现列表中排除。

加载顺序

discoverAndLoadHooks 构建一个扁平的 allPaths 列表,按解析后的绝对路径去重,然后 loadHooks 按该顺序迭代。 每个发现目录内的文件顺序取决于 readdir 输出;钩子加载器不执行额外排序。

运行时处理器顺序

HookRunner 内部,顺序由注册序列决定:

  1. hooks 数组顺序
  2. 每个钩子/事件的处理器注册顺序

按事件类型的冲突行为:

命令/渲染器冲突:

UI 交互(HookContext.ui

HookUIContext 包括:

ctx 包括 hasUIcwdsessionManagermodelRegistry、当前 modelisIdle()abort()hasQueuedMessages()

在没有 UI 运行时,默认 no-op 上下文行为是:

状态行行为

通过 ctx.ui.setStatus(key, text) 设置的钩子状态文本:

错误传播与回退

加载时

事件时

HookRunner.emit(...) 对大多数事件捕获处理器错误并向监听者发射 HookErrorhookPatheventerror),然后继续。

emitToolCall(...) 更严格:处理器错误在那里不会被吞掉;它们传播到调用者。在 HookToolWrapper 中,这会阻断工具调用(fail-safe)。

真实 API 示例

阻断不安全的 bash 命令

import type { HookAPI } from "@musepi/pi-coding-agent/extensibility/hooks";

export default function (pi: HookAPI): void {
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return;
    const cmd = String(event.input.command ?? "");
    if (!cmd.includes("rm -rf")) return;

    if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" };
    const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`);
    if (!ok) return { block: true, reason: "user denied command" };
  });
}

在执行后编辑工具输出

import type { HookAPI } from "@musepi/pi-coding-agent/extensibility/hooks";

export default function (pi: HookAPI): void {
  pi.on("tool_result", async (event) => {
    if (event.toolName !== "read" || event.isError) return;

    const redacted = event.content.map((chunk) => {
      if (chunk.type !== "text") return chunk;
      return {
        ...chunk,
        text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]"),
      };
    });

    return { content: redacted };
  });
}

按 LLM 调用修改模型上下文

import type { HookAPI } from "@musepi/pi-coding-agent/extensibility/hooks";

export default function (pi: HookAPI): void {
  pi.on("context", async (event) => {
    const filtered = event.messages.filter(
      (msg) => !(msg.role === "custom" && msg.customType === "debug-only"),
    );
    return { messages: filtered };
  });
}

注册带有命令安全上下文方法的斜杠命令

import type { HookAPI } from "@musepi/pi-coding-agent/extensibility/hooks";

export default function (pi: HookAPI): void {
  pi.registerCommand("handoff", {
    description: "Create a new session with setup message",
    handler: async (_args, ctx) => {
      await ctx.waitForIdle();
      await ctx.newSession({
        parentSession: ctx.sessionManager.getSessionFile(),
        setup: async (sm) => {
          sm.appendMessage({
            role: "user",
            content: [
              { type: "text", text: "Continue from prior session summary." },
            ],
            timestamp: Date.now(),
          });
        },
      });
    },
  });
}

导出面

src/extensibility/hooks/index.ts 和包子路径 @musepi/pi-coding-agent/extensibility/hooks 导出:

包根(@musepi/pi-coding-agent)不重新导出 HookAPI;从钩子子路径导入遗留钩子类型。