SessionStart hook output silently dropped when bare {additionalContext} shape coexists with raw-text hook on same matcher
Summary
When two SessionStart hooks fire on the same matcher with mixed output protocols — one emitting raw text on stdout, the other emitting bare {"additionalContext": "..."} JSON — Claude Code surfaces the raw-text hook's banner to the session and silently drops the bare-JSON hook's output. Switching the bare-JSON hook to the documented {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "..."}} envelope fixes it. There's no warning, no error, no log entry indicating the drop happened.
Reproduction
Minimal setup with two plugins, each registering a SessionStart hook with matcher startup|clear|compact:
Plugin A (hooks.json):
{
"hooks": {
"SessionStart": [{
"matcher": "startup|clear|compact",
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/banner.sh" }]
}]
}
}
banner.sh:
#!/usr/bin/env bash
cat <<EOF2
=== Plugin A banner ===
Some context.
EOF2
Plugin B (hooks.json):
{
"hooks": {
"SessionStart": [{
"matcher": "startup|clear|compact",
"hooks": [{ "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs" }]
}]
}
}
session-start.mjs:
console.log(JSON.stringify({ additionalContext: "Plugin B says: Session ID is abc123" }));
Install both plugins. Start a session. The agent's context contains:
SessionStart:clear hook success: === Plugin A banner ===
Some context.
Plugin B's Plugin B says: Session ID is abc123 is never delivered to the agent's context. No error surfaces.
Expected behavior
At minimum one of:
- Both hook outputs surface (current docs state "Multiple hooks' values are concatenated", though that wording refers to different matcher groups — same-matcher behavior is undocumented).
- The undocumented bare-JSON shape is rejected with an explicit error or warning (not silent).
- Documentation explicitly states which output protocols can/cannot coexist on the same matcher.
Actual behavior
Plugin B's output is silently discarded. The agent has no visibility into the drop. Plugin authors discover the problem only when downstream behavior breaks (in our case: a boot skill that needs the session UUID couldn't proceed because the UUID line that should have been in context was missing).
Workaround
Use the documented hookSpecificOutput envelope for SessionStart hooks:
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: "Plugin B says: Session ID is abc123"
}
}));
This restores Plugin B's banner. Both Plugin A's raw text and Plugin B's wrapped JSON now surface together.
Real-world impact
- Plugins that have been emitting bare
{additionalContext: "..."}on SessionStart for a long time silently lose their banners the moment another plugin registering on the same matcher is installed alongside them. - The breakage is non-local: the conflict surfaces only when both plugins are present, so a plugin that "worked fine" suddenly stops working when an unrelated plugin lands on the user's machine.
- Hooks that gate downstream agent behavior (e.g. surfacing a session UUID the agent must use) fail in a particularly confusing way: the agent sees the ops-style banner and has no signal that another hook also ran.
Reproduced against Claude Code on Windows 10, Node 22, plugin manifest schema as documented in hooks.md.
Suggested fixes
- Documentation: explicitly state in hooks.md that SessionStart (and other hooks that allow
additionalContext) require thehookSpecificOutputenvelope; raw-text fallback behavior should be documented if it's intentional, removed if it isn't. - Harness: when a hook emits valid JSON that doesn't match the expected schema (e.g. bare
additionalContextwithouthookSpecificOutput), log a warning. Silent drop is the worst outcome. - Harness: if multiple SessionStart hooks on the same matcher write
additionalContext, concatenate them (matching the documented "Multiple hooks' values are concatenated" semantics for cross-matcher cases).
🤖 Generated with Claude Code
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗