PermissionRequest hook systemMessage is accepted and logged as success but never rendered at the ExitPlanMode approval prompt
Summary
A PermissionRequest hook matching ExitPlanMode returns a valid systemMessage.
Claude Code parses it, validates it, and writes success to the debug log — then never
displays it. The documented contract for systemMessage is "Display a message to the user
(all hooks)", so this looks like a rendering gap specific to the plan-approval prompt
rather than intended behavior.
Environment
- Claude Code 2.1.119
- macOS (darwin 22.6.0)
- Node.js v24.4.1
- Model: Opus 4.7 (1M context)
- Reproduced in both the terminal TUI and the VS Code extension
- Reproduced with the plugin loaded via
--plugin-dirand, separately, with the hook
declared in a project .claude/settings.local.json — five consecutive runs total
Steps to reproduce
- Create a plugin (or settings entry) with this hook:
``json``
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{ "type": "command", "command": "node", "args": ["/path/to/hook.mjs", "hook"], "timeout": 15 }
]
}
]
}
}
- Have the hook print a
systemMessageand exit 0, with no decision:
``js``
process.stdout.write(JSON.stringify({ systemMessage: 'HELLO FROM THE HOOK' }) + '\n');
- Start Claude Code interactively, enter plan mode, and request any plan.
- When the "Ready to code?" approval card appears, look for the message.
Expected
HELLO FROM THE HOOK appears to the user, as it does for other hook events and as the
in-product documentation states ("systemMessage — Display a message to the user (all
hooks)").
Actual
Nothing is displayed. The message appears nowhere on screen and nowhere in the session
transcript JSONL. The debug log shows the hook succeeding:
[DEBUG] executePermissionRequestHooks called for tool: ExitPlanMode
[DEBUG] Hooks: Parsed initial response: {"systemMessage":"ApprovalPilot ▸ Context: 34k / 1.0M (3%) — LOW\n…"}
[DEBUG] Hooks: Initial response is not async, continuing normal processing
[DEBUG] Successfully parsed and validated hook JSON output
[DEBUG] "Hook PermissionRequest:ExitPlanMode (PermissionRequest) success:
{\"systemMessage\":\"ApprovalPilot ▸ Context: 34k / 1.0M (3%) — LOW\\n…\"}"
The hook's side effects (writing a file, copying to the clipboard) all occur, confirming
it ran to completion. Only the display is missing.
What was ruled out
- Not the hook's output. Claude Code logs `Successfully parsed and validated hook JSON
output and reports success`.
- Not a missing
hookSpecificOutput. The output is deliberately decision-free so the
native flow passes through. Adding a decision isn't desirable here — the goal is to
inform, not to decide.
- Not
PreToolUsebeing the correct event instead. In the same runs, only
PermissionRequest hooks execute for ExitPlanMode; no PreToolUse activity is logged
for that tool.
- Not the remote-control bridge. Permission traffic routes through
[remote-bridge] Sent control_request / control_response, but setting
"disableRemoteControl": true produces identical behavior.
- Not the terminal UI specifically. The same hook, same plan, same absence of output in
the VS Code extension, which renders the approval prompt with a different UI. The hook
fires in both (verified by its side effects) and displays in neither.
- Not the plugin loader. Identical behavior whether the hook arrives via a plugin's
hooks/hooks.json or a plain .claude/settings.local.json entry.
- Not headless mode. This is an interactive TTY session. (Separately: in
-pmode the
PermissionRequest event never fires at all — verified with a wildcard matcher on a
Bash call that did execute. That may be intended, but it's worth documenting.)
Related issues — and why this one is distinct
There is an existing cluster about systemMessage not reaching the user, but each of those
is about a surface not rendering a message the terminal does render:
- #78266 —
UserPromptSubmitsystemMessagedropped in Desktop and VS Code - #86168 — Stop-hook
systemMessagenot rendered in the VS Code extension, noting it works
in the terminal today
- #80882 —
SessionStartsystemMessagenot surfaced in the VS Code side panel - #77163 — asks to relabel the terminal's rendering of hook messages, quoting real terminal
output in the form SessionStart:startup says: …
That last one is the useful comparison: it demonstrates that hook systemMessage does
render in the terminal TUI for other events, with a <event> says: prefix. This report is
the inverse — PermissionRequest at the plan-approval prompt renders in no surface,
terminal included. So it looks less like the known per-surface gaps and more like thePermissionRequest result path never reaching the renderer that other events use.
Impact
Any hook that wants to give the user information at the moment they approve a plan has
no way to reach them. That decision point — whether to implement in this session or hand
off to a fresh one — is exactly where extra context is most valuable, and it's the one
prompt where hooks can't speak.
Workaround
Returning terminalSequence with an OSC 9 or OSC 777 sequence does reach the user as a
desktop notification, so the plumbing is otherwise intact. That's a workable substitute,
but it depends on terminal support and can't be the default.
Suggested fix
Render systemMessage from PermissionRequest hooks above the approval prompt, as
happens for other hook events.
Secondary documentation note
<https://code.claude.com/docs/en/hooks> describes the PermissionRequest decision object
as decision: "allow" | "deny" | "defer", but the shipped 2.1.119 validator rejects that
and expects {"behavior": "allow"} / {"behavior": "deny", …}. Similarly,
<https://code.claude.com/docs/en/plugins-reference> lists displayName as a validplugin.json field and a top-level description for marketplace.json, both of whichclaude plugin validate rejects in 2.1.119 (the marketplace description belongs undermetadata.description). Worth reconciling the docs with the shipped schema.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗