Skip to content

fix: detect forked subagents from the hook payload, not env alone - #2031

Open
abseg wants to merge 1 commit into
danielmiessler:mainfrom
abseg:fix/fork-aware-subagent-detection
Open

fix: detect forked subagents from the hook payload, not env alone#2031
abseg wants to merge 1 commit into
danielmiessler:mainfrom
abseg:fix/fork-aware-subagent-detection

Conversation

@abseg

@abseg abseg commented Aug 30, 2026

Copy link
Copy Markdown

Problem

isSubagentContext() tests environment variables only. A forked subagent does not reliably set any of them — including CLAUDE_CODE_FORK_SUBAGENT — and it shares the parent's session_id byte for byte. Measured live: a fork's PostToolUse hook process had every marker in the union unset.

So the test returns false inside a fork, and every consumer treats the fork as the main session:

  • PostToolObserver injected main-session nudges and the SYSTEM line into a fork's own transcript.
  • ISASync injected the ascent strip there too.
  • ConfigEvalFire spawned the entire behavioural regression suite when a fork touched a sentinel file.

This is the residual half of #1831: the fork marker was added for exactly this, and on at least some runtimes it is never set.

Fix

PostToolUse carries agent_id / agent_type for delegated calls and omits them in the main session, which makes the payload the reliable signal.

isSubagentContext() now takes the parsed hook input as an optional first argument and returns true when either field is present. The environment union is unchanged and still covers the non-fork delegate families and standalone runs, where no payload exists. Existing no-argument callers keep working — the parameter is optional, so this is not a breaking change.

The three consumers above pass their already-parsed input through. ConfigEvalFire.readInput() is widened to carry the two fields.

Scope note

In ISASync, only the ascent strip is gated. syncToWorkJson is deliberately left ungated and never was gated: a fork editing an ISA is real work on the run, and its progress belongs in work.json. Only the strip is per-context, so only the strip is gated.

Verification

All four files parse-check clean (bun build --target=bun). input is confirmed in scope at each insertion site, and no duplicate imports are introduced.

isSubagentContext() tested environment variables only. A forked subagent
does not reliably set any of them — including CLAUDE_CODE_FORK_SUBAGENT —
and it shares the parent's session_id, so the test returned false inside a
fork and every consumer treated the fork as the main session.

Observed effects: PostToolObserver injected main-session nudges and the
SYSTEM line into a fork's own transcript, ISASync injected the ascent
strip there too, and ConfigEvalFire spawned the full behavioural suite
when a fork touched a sentinel file.

PostToolUse carries agent_id/agent_type for delegated calls and omits them
in the main session, which makes the payload the reliable signal.
isSubagentContext() now takes the parsed hook input as an optional first
argument and returns true when either field is present; the environment
union is unchanged and still covers non-fork delegates and standalone
runs, where no payload exists. Existing no-argument callers keep working.

ISASync scope note: only the ascent strip is gated. syncToWorkJson is
deliberately left ungated, because a fork editing an ISA is real work on
the run and its progress belongs in work.json.
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