Z ZPI 文档 v0.3.8
介绍页

源码

packages/plugin-compat/src/command-hooks.ts

typescript271 行查看原文
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { isJsonObject } from "@zpi-dev/ai";
import {
  getShellConfig,
  getShellEnv,
  type HookInput,
  type HookOutput,
  type HookRegistration,
  hookEvents,
  killProcessTree,
} from "@zpi-dev/coding-agent";

export interface CommandHook {
  event: string;
  matcher?: string;
  type: "command" | "process";
  command: string;
  args: string[];
  timeoutMs: number;
}
export interface CommandHookContext {
  root: string;
  dataDirectory: string;
  id: string;
  name: string;
  transcriptPath?: string;
  env?: Record<string, string | undefined>;
  validate?: () => void;
}
const maxFileBytes = 2 * 1024 * 1024;
const maxOutputBytes = 128 * 1024;
function fail(message: string): never {
  throw new Error(`configuration: 钩子:${message}`);
}
const aliases: Record<string, string> = {
  Bash: "bash",
  Read: "read",
  Edit: "edit",
  Write: "write",
  Task: "Agent",
};
function matcher(value: unknown): string | undefined {
  if (value === undefined || value === "" || value === "*") return undefined;
  if (typeof value !== "string") fail("hook matcher 必须是字符串");
  let pattern: RegExp;
  try {
    pattern = new RegExp(value);
  } catch {
    fail("hook matcher 正则表达式无效");
  }
  const nativeNames = Object.entries(aliases)
    .filter(([alias]) => pattern.test(alias))
    .map(([, native]) => native);
  return nativeNames.length ? `(?:${value})|^(?:${nativeNames.join("|")})$` : value;
}
export function parseCommandHook(hook: unknown, event: string, match: unknown): CommandHook {
  if (!(hookEvents as readonly string[]).includes(event)) fail(`暂不支持 hook 事件:${event}`);
  if (
    !isJsonObject(hook) ||
    !["command", "process"].includes(hook.type as string) ||
    typeof hook.command !== "string" ||
    !hook.command.trim()
  )
    fail("只支持 command/process hooks");
  if (
    Object.keys(hook).some(
      (key) => !["type", "command", "args", "timeout", "timeoutMs", "statusMessage", "async"].includes(key),
    ) ||
    (hook.async !== undefined && hook.async !== false)
  )
    fail("暂不支持此 hook 字段或异步 hook");
  if (
    (hook.timeout !== undefined && typeof hook.timeout !== "number") ||
    (hook.timeoutMs !== undefined && typeof hook.timeoutMs !== "number")
  )
    fail("hook 超时必须是数字");
  const timeoutMs = hook.timeoutMs ?? (typeof hook.timeout === "number" ? hook.timeout * 1000 : 30000);
  if (
    typeof timeoutMs !== "number" ||
    !Number.isSafeInteger(timeoutMs) ||
    timeoutMs < 1 ||
    timeoutMs > 300000
  )
    fail("hook 超时必须在 1–300000 毫秒之间");
  const args = hook.args === undefined ? [] : hook.args;
  if (
    !Array.isArray(args) ||
    args.some((arg) => typeof arg !== "string") ||
    (hook.type === "command" && args.length)
  )
    fail("hook args 无效;command 使用 shell 字符串,process 使用 argv");
  return {
    event,
    matcher: matcher(match),
    type: hook.type as CommandHook["type"],
    command: hook.command,
    args: args as string[],
    timeoutMs,
  };
}

export function decodeHookOutput(value: unknown, event: HookInput["event"]): HookOutput | undefined {
  if (!isJsonObject(value)) fail("hook 输出必须是 JSON 对象");
  const specific = isJsonObject(value.hookSpecificOutput) ? value.hookSpecificOutput : {};
  if (specific.hookEventName !== undefined && specific.hookEventName !== event) fail("hook 输出事件不匹配");
  const context = specific.additionalContext ?? value.additionalContext ?? value.additional_context;
  const reason = specific.permissionDecisionReason ?? value.reason ?? value.stopReason;
  const block =
    value.block === true ||
    value.continue === false ||
    value.decision === "block" ||
    ["deny", "ask"].includes(specific.permissionDecision as string);
  if (
    (context !== undefined && typeof context !== "string") ||
    (reason !== undefined && typeof reason !== "string")
  )
    fail("hook 上下文或理由必须是字符串");
  if (event === "Stop" && block)
    return { additionalContext: (reason as string) || "Hook requested another verification." };
  const updatedInput = specific.updatedInput ?? value.updatedInput;
  return {
    ...(context ? { additionalContext: context as string } : {}),
    ...(block ? { block: true, reason: reason as string | undefined } : {}),
    ...(updatedInput !== undefined ? { updatedInput } : {}),
  };
}
export function hookInputPayload(input: HookInput, transcriptPath?: string) {
  return {
    ...input,
    hookEventName: input.event,
    hook_event_name: input.event,
    session_id: input.sessionId,
    ...(transcriptPath ? { transcriptPath: transcriptPath, transcript_path: transcriptPath } : {}),
    ...("toolName" in input
      ? {
          tool_name:
            Object.entries(aliases).find(([, native]) => native === input.toolName)?.[0] ?? input.toolName,
          tool_input: input.toolInput,
          tool_use_id: input.toolCallId,
          tool_response: input.toolResult,
          error: input.toolResult?.isError ? JSON.stringify(input.toolResult.content) : undefined,
        }
      : {}),
    ...(input.event === "Stop"
      ? { stop_hook_active: input.stopHookActive, last_assistant_message: input.response }
      : {}),
    ...(input.event === "Notification" ? { notification_type: input.notificationType } : {}),
  };
}
async function commandHook(
  hook: CommandHook,
  input: HookInput,
  data: CommandHookContext,
  signal: AbortSignal,
): Promise<HookOutput | undefined> {
  signal.throwIfAborted();
  const pluginData = data.dataDirectory;
  await mkdir(pluginData, { recursive: true, mode: 0o700 });
  signal.throwIfAborted();
  const env: NodeJS.ProcessEnv = {
    ...(data.env ?? getShellEnv()),
    ZPI_PROJECT_DIR: input.cwd,
    ZCODE_PROJECT_DIR: input.cwd,
    CLAUDE_PROJECT_DIR: input.cwd,
  };
  for (const prefix of ["ZPI", "ZCODE", "CLAUDE"])
    Object.assign(env, {
      [`${prefix}_PLUGIN_ROOT`]: data.root,
      [`${prefix}_PLUGIN_DATA`]: pluginData,
      [`${prefix}_PLUGIN_ID`]: data.id,
      [`${prefix}_PLUGIN_NAME`]: data.name,
    });
  const expand = (text: string) =>
    text.replace(/\$\{((?:ZPI|ZCODE|CLAUDE)_PLUGIN_(?:ROOT|DATA))\}/g, (_, key: string) => env[key] ?? "");
  const payload = hookInputPayload(input, data.transcriptPath);
  const stdin = JSON.stringify(payload);
  if (Buffer.byteLength(stdin) > maxFileBytes) fail("hook 输入超过 2 MiB");
  const invocation =
    hook.type === "process"
      ? { executable: expand(hook.command), args: hook.args.map(expand) }
      : (() => {
          const shell = getShellConfig();
          return { executable: shell.shell, args: [...shell.args, hook.command] };
        })();
  const child = spawn(invocation.executable, invocation.args, {
    cwd: input.cwd,
    env,
    detached: process.platform !== "win32",
    windowsHide: true,
    stdio: "pipe",
  });
  return new Promise((resolveResult, reject) => {
    const chunks: Buffer[] = [],
      errors: Buffer[] = [];
    let size = 0,
      failure: Error | undefined;
    const stop = (error: Error) => {
      failure ??= error;
      if (child.pid) killProcessTree(child.pid);
    };
    const abort = () => stop(new Error("Hook cancelled"));
    signal.addEventListener("abort", abort, { once: true });
    if (signal.aborted) abort();
    const timer = setTimeout(() => stop(new Error("Hook timed out")), hook.timeoutMs);
    for (const stream of [child.stdout, child.stderr])
      stream.on("data", (chunk: Buffer) => {
        size += chunk.length;
        if (size > maxOutputBytes) stop(new Error("Hook output exceeds 128 KiB"));
        else (stream === child.stdout ? chunks : errors).push(Buffer.from(chunk));
      });
    child.stdin.on("error", () => {});
    child.stdin.end(stdin);
    child.on("error", (error) => {
      failure ??= error;
    });
    child.on("close", (code) => {
      clearTimeout(timer);
      signal.removeEventListener("abort", abort);
      if (signal.aborted) {
        reject(signal.reason);
        return;
      }
      if (failure) {
        reject(failure);
        return;
      }
      const text = Buffer.concat(chunks).toString("utf8").trim();
      const reason = Buffer.concat(errors).toString("utf8").trim() || text;
      if (code === 2 && ["PreToolUse", "UserPromptSubmit", "Stop"].includes(input.event)) {
        resolveResult(
          input.event === "Stop"
            ? { additionalContext: reason || "Hook requested verification." }
            : { block: true, reason: reason || "Blocked by plugin hook" },
        );
        return;
      }
      if (code !== 0) {
        reject(new Error(`Plugin hook exited with code ${code}`));
        return;
      }
      try {
        resolveResult(
          text.startsWith("{") || text.startsWith("[")
            ? decodeHookOutput(JSON.parse(text), input.event)
            : ["SessionStart", "UserPromptSubmit"].includes(input.event) && text
              ? { additionalContext: text }
              : undefined,
        );
      } catch (error) {
        reject(error);
      }
    });
  });
}
export function bindCommandHooks(
  hooks: readonly CommandHook[],
  context: CommandHookContext,
): HookRegistration[] {
  return hooks.map((hook) => ({
    event: hook.event as HookInput["event"],
    matcher: hook.matcher,
    timeoutMs: Math.min(hook.timeoutMs + 1000, 300000),
    onError: "block",
    handler: async (input, signal) => {
      context.validate?.();
      return commandHook(hook, input, context, signal);
    },
  }));
}