Skip to content

fix(hooks): detect subagents from hook stdin (agent_id / agent_type, SessionStart source=fork), drop the fork feature-flag test - #1958

Open
theNetworkChuck wants to merge 1 commit into
danielmiessler:mainfrom
theNetworkChuck:fix/subagent-guard-reads-hook-input
Open

fix(hooks): detect subagents from hook stdin (agent_id / agent_type, SessionStart source=fork), drop the fork feature-flag test#1958
theNetworkChuck wants to merge 1 commit into
danielmiessler:mainfrom
theNetworkChuck:fix/subagent-guard-reads-hook-input

Conversation

@theNetworkChuck

Copy link
Copy Markdown

Summary

isSubagentContext() keys on environment variables. That cannot work for forked subagents, and the workaround it shipped with (CLAUDE_CODE_FORK_SUBAGENT === '1') turned every main session into a "subagent" and silently disabled nine hooks (#1864, #1911, PR #1942).

This PR removes the fork-flag row (same hunk as #1942) and replaces it with the signal that actually distinguishes a fork: the hook's stdin JSON. Consumers that already parse stdin pass it to the guard; the two SessionStart consumers that did not read stdin now do, best-effort.

Why an env var can never mark a fork

Measured on Claude Code 2.1.241 by spawning a fork and dumping its environment from inside: a fork's environment is byte-identical to its parent's — same CLAUDE_CODE_SESSION_ID, same CLAUDE_PID, no CLAUDE_AGENT_TYPE / CLAUDE_CODE_SUBAGENT_NAME / CLAUDE_CODE_SUBAGENT_TYPE, and CLAUDE_CODE_FORK_SUBAGENT=1 in both. Nothing in process.env separates them, so with the flag row removed (#1942) the original #1831 leak (forks re-injecting main-session context) is open again.

What does mark a fork

The hooks reference documents agent_id and agent_type as common input fields "for subagent hooks", and lists fork as a SessionStart matcher (i.e. source: "fork" on that event's input). Observed on the same 2.1.241 fork: its PostToolUse input carries agent_type: "fork" plus an agent_id; an ordinary delegate carries agent_type: "general-purpose"; main-session input carries neither.

Change

lib/subagent.ts

Consumers now pass their parsed stdin: ISASync, SystemChangeSurface (HookInput gains optional agent_id/agent_type), ConfigEvalFire, MemoryReviewFire, MemoryTurnStart, KittyEnvPersist (guard moved after its existing stdin read; source comment now includes fork), LoadContext (adds a best-effort stdin read). LoadMemory and MemoryDeltaSurface are unchanged: their guarded paths are standalone probes, and their real caller MemoryTurnStart is now guarded on stdin.

lib/hook-io.tsHookInput documents the optional agent_id, agent_type, source fields.

No new dependencies; hooks stay importable before bun install.

Verification

All runs with CLAUDE_CODE_FORK_SUBAGENT=1 in the environment (what settings.system.json and Claude Code ≥ 2.1.232 both set), against a throwaway HOME, using stdin shapes copied from real captured hook input:

  • Guard unit cases (18): main-session input with the flag set → false for PostToolUse / Stop / SessionStart-startup; fork PostToolUse (agent_type: "fork") and SessionStart source: "fork"true; ordinary delegate stamp → true; each surviving env marker alone → true; null / string / array / empty-string / non-string / source: "fork" on a non-SessionStart event → false, none throw.
  • Hook-level: MemoryReviewFire writes its per-session state on a main Stop and writes nothing on a fork-stamped Stop; LoadContext runs on source: "startup" and prints its skip line on source: "fork"; KittyEnvPersist same split; SystemChangeSurface.run() returns its surface line for a main write and null for a fork-stamped one.
  • Each edited hook transpiles (bun build --target=bun --no-bundle).

Relationship to open work: stacks on #1942 (the removal hunk is the same; merging either first leaves a trivial conflict on that one hunk). Closes the re-opened #1831 concern and the "what is the reliable fork marker" follow-up noted on #1864 / #1911.

`isSubagentContext()` tested `CLAUDE_CODE_FORK_SUBAGENT === '1'` as a fork
marker. That variable is the fork feature-ENABLE flag: settings.system.json
sets it in the global env block and Claude Code >= 2.1.232 sets it in every
interactive session, so every main session classified as a subagent and nine
hooks silently no-op'd (danielmiessler#1864, danielmiessler#1911, PR danielmiessler#1942).

Dropping the row alone re-opens danielmiessler#1831: a fork's environment is byte-identical
to its parent's, so no env var can mark one. The marker that does is the
hook's stdin — the documented `agent_id`/`agent_type` fields (a fork arrives
as `agent_type: "fork"`) and SessionStart's `source: "fork"`.

- lib/subagent.ts: remove the fork-flag row; add isSubagentHookInput(input)
  and an optional `input` parameter on isSubagentContext() (env union OR
  stdin stamp). Zero-arg callers are unchanged.
- ISASync, SystemChangeSurface, ConfigEvalFire, MemoryReviewFire,
  MemoryTurnStart, KittyEnvPersist pass their parsed stdin to the guard;
  LoadContext adds a best-effort stdin read for the same purpose.
- lib/hook-io.ts: document the optional agent_id / agent_type / source fields.

Verified with the flag set in the environment: main-session input runs,
fork- and delegate-stamped input skips, across Stop / SessionStart /
PostToolUse shapes copied from real hook input.

Refs danielmiessler#1831 danielmiessler#1864 danielmiessler#1911 danielmiessler#1942
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant