Plugin hooks module: classic.* events (classic.Stop, classic.PreToolUse, classic.Notification, classic.PermissionRequest) never reach the module, while settings hooks for the same events fire in the same session (2.1.295)

Status Open
Reported on v2.1.295
Maintainer reply None cached
Activity 0 comments · opened Oct 9, 2026

Summary

In a plugin hooks module (the function-hooks API, hooks/hooks.json → { "modules": ["./register.ts"] }), hooks registered on the classic bridge events classic.Stop, classic.PreToolUse, classic.Notification and classic.PermissionRequest never run, while command hooks for the same events in settings.json run in the same session. The API's own type declarations (written by the engine, // Written by Claude Code 2.1.295) promise the opposite, and claude plugin validate accepts the subscriptions and lists them as gating hooks.

Environment

  • Claude Code 2.1.295 (also observed on 2.1.294), macOS (Darwin 25.6.0), terminal and headless -p
  • Plugin loaded with --plugin-dir <folder>; same result for a mod loaded from the session's mods folder on five long-running agents (see "In production" below)
  • Permission mode: both --dangerously-skip-permissions and the default mode (see the two runs)

What the API promises

<mod folder>/.claude-plugin/types/claude-code/index.d.ts (the file the engine writes when it loads a mod; header // Written by Claude Code 2.1.295. and // EARLY ACCESS: this surface may change between releases without notice.), lines 1231-1238:

  /**
   * The name of a classic hook event as a function-hooks event: the settings
   * hook's own name under `classic` (`classic.Stop`, `classic.PreToolUse`).
   *
   * Each fires wherever the engine runs the classic hook, whether or not any
   * settings hook is configured, and reaches a `*` or `classic.*` hook as it
   * reaches a named one.
   */
  export type ClassicEventName = `classic.${ClassicHookEvent}`;

and the plugin-authoring skill's reference.md: "The settings hooks' own events are hookable as classic.<Event> (classic.Stop, classic.SessionEnd), e being what that hook receives on stdin, transcript_path and the other base fields included."

Minimal repro (3 files + one settings file)

classic-probe/.claude-plugin/plugin.json

{ "name": "classic-probe", "version": "0.1.0", "description": "Probe: do the classic.* hook events reach a hooks module?" }

classic-probe/hooks/hooks.json

{ "modules": ["./register.ts"] }

classic-probe/hooks/register.ts

import type { Register } from 'claude-code'

// Every hook appends one line to a file. The native events are the positive control
// (known to arrive); the classic.* events are what the types promise.
const LOG = '/tmp/classic-probe.log'
const mark = ($: any, line: string) =>
  $.process.run(['/bin/sh', '-c', `printf '%s %s\\n' "$(date +%T)" ${JSON.stringify(line)} >> ${LOG}`], { timeoutMs: 5_000 })

export const register: Register = on => {
  on('session.start', async ($, e, next) => { await mark($, 'session.start'); return next(e) })
  on('turn.complete', async ($, e, next) => { await mark($, 'turn.complete'); return next(e) })
  on('tool.call', async ($, e, next) => { await mark($, `tool.call ${e.tool}`); return next(e) })

  on('classic.PreToolUse', async ($, e, next) => { await mark($, 'classic.PreToolUse'); return next(e) })
  on('classic.Stop', async ($, e, next) => { await mark($, 'classic.Stop'); return next(e) })
  on('classic.Notification', async ($, e, next) => { await mark($, `classic.Notification ${e.notification_type}`); return next(e) })
  on('classic.PermissionRequest', async ($, e, next) => { await mark($, `classic.PermissionRequest ${e.tool_name}`); return next(e) })
}

probe-settings.json (the positive control: the same events as classic command hooks)

{ "hooks": {
    "Stop":              [ { "hooks": [ { "type": "command", "command": "printf '%s settings:Stop\\n' \"$(date +%T)\" >> /tmp/classic-probe-settings.log" } ] } ],
    "Notification":      [ { "matcher": "", "hooks": [ { "type": "command", "command": "printf '%s settings:Notification %s\\n' \"$(date +%T)\" \"$(jq -r .notification_type)\" >> /tmp/classic-probe-settings.log" } ] } ],
    "PermissionRequest": [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "printf '%s settings:PermissionRequest %s\\n' \"$(date +%T)\" \"$(jq -r .tool_name)\" >> /tmp/classic-probe-settings.log" } ] } ],
    "PreToolUse":        [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "printf '%s settings:PreToolUse %s\\n' \"$(date +%T)\" \"$(jq -r .tool_name)\" >> /tmp/classic-probe-settings.log" } ] } ]
} }

claude plugin validate classic-probe accepts the module and lists the classic hooks:

  ❯ ./register.ts hooks: session.start, turn.complete, tool.call, classic.PreToolUse, classic.Stop, classic.Notification, classic.PermissionRequest
  ❯ ./register.ts gating hook without .catch: classic.Stop
  ❯ ./register.ts gating hook without .catch: classic.Notification
  ❯ ./register.ts gating hook without .catch: classic.PermissionRequest
  ❯ ./register.ts calls: $.process.run (via mark)
✔ Validation passed with warnings

Run (twice, once with bypass permissions and once in the default mode):

rm -f /tmp/classic-probe.log /tmp/classic-probe-settings.log
claude -p 'Run exactly this shell command with the Bash tool: echo probe-ok . Then reply with the single word done.' \
  --plugin-dir ./classic-probe --settings ./probe-settings.json --dangerously-skip-permissions
claude -p 'Run exactly this shell command with the Bash tool: echo probe-ok . Then reply with the single word done.' \
  --plugin-dir ./classic-probe --settings ./probe-settings.json

Actual

/tmp/classic-probe.log (the module): only the native events, no classic.* line in either run:

08:18:44 session.start
08:18:47 tool.call Bash
08:18:48 turn.complete
08:18:53 session.start
08:18:56 tool.call Bash
08:18:57 turn.complete

/tmp/classic-probe-settings.log (the settings command hooks, same two sessions): the classic events did run:

08:18:47 settings:PreToolUse Bash
08:18:48 settings:Stop
08:18:56 settings:PreToolUse Bash
08:18:57 settings:Stop

So in the same session, at the same second, the engine ran the classic PreToolUse and Stop hooks (the settings hooks prove it) and the module's classic.PreToolUse / classic.Stop hooks did not run. No "refused" line appeared in the transcript for the module.

Expected

Per the types quoted above, each classic.<Event> hook "fires wherever the engine runs the classic hook, whether or not any settings hook is configured". We expected two classic.PreToolUse and two classic.Stop lines in /tmp/classic-probe.log.

In production (same build)

  • A mod running on five long-lived agents subscribed to classic.Stop, classic.StopFailure, classic.Notification and classic.PermissionRequest and recorded, over 15 hours, 147 turn ends (from turn.complete) and 0 rows from any classic.* hook.
  • A second, independent probe module (same shape, logging tool.call, tool.check, turn.complete and all of classic.PermissionRequest/Notification/Stop/PreToolUse/PostToolUse/UserPromptSubmit/SessionStart), run five times headless (claude -p, with and without --dangerously-skip-permissions, with and without --settings logging hooks) and once interactively (claude --permission-mode default --settings … --plugin-dir …, a touch command that opened the permission dialog):
  • headless run E, settings hooks log: UserPromptSubmit, PreToolUse, PreToolUse, Stop; the module's log in the same run: session.start, tool.call Bash, tool.check Bash ask, tool.call Bash, tool.check Bash ask, turn.complete, no classic.* line;
  • interactive run, settings hooks log: UserPromptSubmit, PreToolUse, PermissionRequest, Notification; the module's log: session.start, tool.call Bash, tool.check Bash ask, no classic.* line (so the engine ran the classic PermissionRequest and Notification hooks while the module's classic.PermissionRequest and classic.Notification hooks did not run);
  • classic.* lines across all six runs: 0; tool.check delivered the ask verdict within 0.8 s of the dialog appearing, so the native events do work.
  • A possibly related observation (a hint only, since event names may be built dynamically): the 2.1.295 bundle contains the literal strings "classic.PreToolUse", "classic.PostToolUse", "classic.PermissionRequest", "classic.PermissionDenied", "classic.SessionStart", "classic.SessionEnd", "classic.SubagentStop", "classic.UserPromptSubmit" but no classic.Stop, classic.StopFailure or classic.Notification anywhere.

Impact and workaround

A mod cannot observe turn ends, API-error ends, notifications or permission prompts through the documented bridge. The workaround that works today is a settings.json command hook for each event that writes the event into the same store the mod reads.

Questions

  1. Is the classic.* bridge expected to deliver these events to a hooks module in 2.1.295, or is the type comment ahead of the engine?
  2. If it is expected, is there any condition (plugin load path, --plugin-dir vs. the mods folder, -p vs. interactive, permission mode) under which it does fire? We saw no difference between --dangerously-skip-permissions and the default mode, nor between -p and interactive.

View original on GitHub ↗