MusePi

Hooks

English | 中文 This document describes the current hook subsystem code in src/extensibility/hooks/*.

Current status in runtime

The default CLI runtime initializes the extension runner path. In current startup flow:

So this file documents the legacy hook subsystem implementation itself (types/loader/runner/wrapper), plus the factory shape still accepted when a discovered hook path is loaded by the extension runner.

Key files

What a hook module is

A hook module must default-export a factory:

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" };
    }
  });
}

The factory can:

Discovery and loading

Default sessions load JS/TS hook factories discovered by hookCapability through the extension runner. discoverExtensionPaths(configuredPaths, cwd) does:

  1. Load native extension modules from the capability registry
  2. Load importable .ts/.js hook factories from the hook capability registry
  3. Append plugin extension entry points
  4. Append explicitly configured paths

The legacy discoverAndLoadHooks(configuredPaths, cwd) helper still exists and does:

  1. Load discovered hooks from capability registry (loadCapability("hooks"))
  2. Append explicitly configured paths (deduped by absolute path)
  3. Call loadHooks(allPaths, cwd)

loadHooks then imports each path and expects a default function.

Path resolution

loader.ts resolves hook paths as:

Event surfaces

Hook events are strongly typed in types.ts.

Session events

Agent/context events

Tool events (pre/post model)

This is the hook subsystem’s core pre/post interception model.

Hook tool interception flow

tool_call handlers
   │
   ├─ any { block: true }? ── yes ──> throw (tool blocked)
   │
   └─ no
      │
      ▼
   execute underlying tool
      │
      ├─ success ──> tool_result handlers can override { content, details }
      │
      └─ error   ──> emit tool_result(isError=true) then rethrow original error

Execution model and mutation semantics

1) Pre-execution: tool_call

HookToolWrapper.execute() emits tool_call before tool execution.

2) Tool execution

Underlying tool executes normally if not blocked.

3) Post-execution: tool_result

After success, wrapper emits tool_result with:

If handler returns overrides:

On tool failure, wrapper emits tool_result with isError: true and error text content, then rethrows original error.

What hooks can mutate

What hooks cannot mutate in this implementation

Ordering and conflict behavior

Discovery-level ordering

Capability providers are priority-sorted (higher first). Dedupe is by capability key, first wins.

For hooks, capability key is ${type}:${tool}:${name}. Shadowed duplicates from lower-priority providers are marked and excluded from effective discovered list.

Load order

discoverAndLoadHooks builds a flat allPaths list, deduped by resolved absolute path, then loadHooks iterates in that order. File order within each discovered directory depends on readdir output; the hook loader does not perform an additional sort.

Runtime handler order

Inside HookRunner, order is deterministic by registration sequence:

  1. hooks array order
  2. handler registration order per hook/event

Conflict behavior by event type:

Command/renderer conflicts:

UI interactions (HookContext.ui)

HookUIContext includes:

ctx includes hasUI, cwd, sessionManager, modelRegistry, current model, isIdle(), abort(), and hasQueuedMessages().

When running with no UI, the default no-op context behavior is:

Status line behavior

Hook status text set via ctx.ui.setStatus(key, text) is:

Error propagation and fallback

Load-time

Event-time

HookRunner.emit(...) catches handler errors for most events and emits HookError to listeners (hookPath, event, error), then continues.

emitToolCall(...) is stricter: handler errors are not swallowed there; they propagate to caller. In HookToolWrapper, this blocks the tool call (fail-safe).

Realistic API examples

Block unsafe bash commands

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" };
  });
}

Redact tool output on post-execution

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 };
  });
}

Modify model context per LLM call

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 };
  });
}

Register slash command with command-safe context methods

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(),
          });
        },
      });
    },
  });
}

Export surface

src/extensibility/hooks/index.ts and the package subpath @musepi/pi-coding-agent/extensibility/hooks export:

The package root (@musepi/pi-coding-agent) does not re-export HookAPI; import legacy hook types from the hooks subpath.