[FEATURE] Documented interactive-vs-headless signal in the SessionStart hook input (so hooks can self-gate for claude -p / nested sessions)
Preflight Checklist
- [x] I have searched existing requests and this feature hasn't been requested yet
- [x] This is a single feature request (not multiple features)
Problem Statement
SessionStart hooks fire in every session — including headless claude -p and nested invocations, e.g. an app or agent that shells out to claude -p via the SDK. A SessionStart hook that is only meaningful interactively (anything that writes to stdout — which enters the model's context — or performs an interactive-only side effect) has no documented way to detect that it's in a headless/nested context and should no-op.
The documented SessionStart input fields don't capture this: source distinguishes startup/resume/clear/compact (how a session began), not interactive vs print-mode. The only signal that currently carries it is the undocumented CLAUDE_CODE_ENTRYPOINT env var (observed: cli = interactive terminal, claude-desktop = desktop app, sdk-cli = headless claude -p). Binding hook logic to an undocumented, unguaranteed variable is fragile across releases.
Two concrete harms we hit:
- Context injection across a layering boundary. When a product drives claude -p, an operator's user-level SessionStart hook fires inside that nested session and its stdout enters the nested model's context — a determinism/reproducibility problem for the embedding app, which can't control operator-level hooks.
- Side effects in headless runs. A hook that dispatches an interpreter fired inside an automated claude -p test on Windows and popped a "Select an app to open python3" OS picker — mid-test, with no interactive user present.
Proposed Solution
Add a documented field to the SessionStart hook input JSON that distinguishes interactive from non-interactive invocations. Any one of:
- interactive: boolean — true for terminal/desktop sessions, false for claude -p/print-mode/SDK-driven; or
- an invocation/mode enum, e.g. "interactive" | "print" (richer is fine: "cli" | "print" | "sdk" | "desktop"); or
- promote CLAUDE_CODE_ENTRYPOINT to a documented, stable signal (its value set + stability guarantee), since it already encodes this.
Ideal UX — a hook self-gates from stdin, no env-var guessing:
import json, sys
data = json.load(sys.stdin)
if not data.get("interactive", True): # documented field
sys.exit(0) # don't run in headless/nested sessions
Alternative Solutions
- CLAUDE_CODE_ENTRYPOINT — works today, but undocumented/unsupported; we use it only as an interim binding.
- TTY detection (isatty) — unreliable; hooks may not inherit a TTY and print-mode vs interactive don't cleanly differ across environments.
- --settings '{"disableAllHooks": true}' on the caller's claude -p — a good caller-side fix (the embedding app opts out), but it doesn't let a hook self-gate for arbitrary/unknown callers, and requires every embedder to know to do it.
- source field — orthogonal (start reason, not interactivity).
Priority
High - Significant impact on productivity
Feature Category
Developer tools/SDK
Use Case Example
- A plugin ships a SessionStart hook that prints a status/survey block for interactive coordinator sessions.
- A separate product uses claude -p (SDK) to run its own read-only model over some input.
- The operator's plugin hook fires inside that nested claude -p, injecting the survey text into the product model's context — and on Windows pops an OS app-picker during an automated test.
- With a documented interactive flag, the hook reads it from stdin and early-exits in the nested case — no injection, no side effect — without depending on an undocumented env var.
Additional Context
- Documented SessionStart input today (per code.claude.com/docs/en/hooks.md): session_id, transcript_path, cwd, source ∈ {startup,resume,clear,compact}, model, session_title, permission_mode, effort, agent_id/agent_type — none expresses interactivity.
- Observed CLAUDE_CODE_ENTRYPOINT values (macOS + Windows probing): cli, claude-desktop, sdk-cli; CLAUDE_CODE_REMOTE unset throughout.
- Environment: claude --version = (fill in); seen on macOS and Windows.