PreToolUse hooks in subagents receive parent session's transcript_path instead of the subagent's own
Environment
- Claude Code version: 2.1.206
- OS: Linux (WSL2, Ubuntu on Windows). Not platform-specific by inspection — the root cause is in Claude Code's own payload-builder JS, not OS-dependent code — but only tested on WSL2, not verified on native Linux/macOS
- Installed via: standard
claudeexecutable
Summary
Inside a Task subagent, the PreToolUse hook payload's agent_id field correctly identifies the subagent, but transcript_path still points at the parent (main) session's transcript file, not the subagent's own transcript under subagents/agent-<agent_id>.jsonl.
Any hook that reads transcript_path to inspect "what happened this turn" (e.g. a gate that checks whether a particular tool/skill was invoked before allowing an edit) ends up scanning the wrong file when running inside a subagent. In our case this caused a PreToolUse gate to systematically deny Edit/Write calls made by subagents, because the gate looked at the parent transcript — which has no record of the subagent's own tool calls — and concluded the required precondition had not been met.
Reproduction
- Register a
PreToolUsehook (any tool matcher) that just logs the payload it receives, in particulartranscript_pathandagent_id:
``bash``
#!/usr/bin/env bash
jq -c '{transcript_path, agent_id, session_id}' >> /tmp/pretooluse-payloads.jsonl
- Start a session and dispatch a
Task(subagent) that performs any tool call subject to the hook (e.g.Edit,Write,Bash). - Inspect the logged payload for the tool call made inside the subagent.
Observed: transcript_path is the parent session's file, e.g. .../projects/<proj>/<main-session-uuid>.jsonl, even though agent_id correctly identifies the subagent (e.g. agent-a54c53c73b09d1014), and a sibling file .../projects/<proj>/<main-session-uuid>/subagents/agent-a54c53c73b09d1014.jsonl exists and contains the subagent's own transcript entries (including tool calls the parent transcript never sees).
These facts were derived from a previously saved incident transcript (payload + both transcript files) plus static extraction of the payload-builder code from the installed 2.1.206 binary (see Evidence), not from a freshly captured live log. The logging snippet above is a suggested way for others to capture and confirm this live.
Expected: transcript_path should point at the subagent's own transcript file.
Evidence
We extracted the minified payload-builder and PreToolUse caller functions from the installed claude 2.1.206 executable (verbatim):
function Cf(e,t,r){let n=t??Ct(),o=r?.agentType??S2(),i=r?.options?.mainLoopModel,s=r?.getAppState?.().effortValue;for(let l of r?.permissionLayers??[])if(l.kind==="effort"&&l.effort!==void 0)s=l.effort;let a=i&&r?.getAppState&&Nx(i)?{level:T3(i,s)}:void 0;return{session_id:n,transcript_path:QI(n),cwd:wt(),prompt_id:Umt()??void 0,permission_mode:e,agent_id:r?.agentId,agent_type:o,effort:a}}
async function*jwt(e,t,r,n,o,i,s=rm,a){let l=n.getAppState(),c=n.agentId??Ct();if(!a?.managedHooksOnly&&!VB("PreToolUse",l,c))return;w(`executePreToolHooks called for tool: ${e}`,{level:"verbose"});let u={...Cf(o,void 0,n),hook_event_name:"PreToolUse",tool_name:e,tool_input:r,tool_use_id:t};yield*ML({hookInput:u,toolUseID:t,matchQuery:e,signal:i,timeoutMs:s,toolUseContext:n,managedHooksOnly:a?.managedHooksOnly})}
jwt (the PreToolUse executor) calls Cf(o, void 0, n) — the second argument, an explicit session-override, is always void 0. Inside Cf, n = t ?? Ct() therefore falls back to Ct(), which resolves the main/current session id, and transcript_path: QI(n) derives the path from that main session id. The tool context n passed as Cf's third argument does carry n.agentId, and jwt does use n.agentId (via c=n.agentId??Ct()) for hook-matching and for the agent_id field it merges in afterward — but agentId is never fed into the transcript_path derivation. It only affects which hooks run and what agent_id says, not which transcript file is reported.
Supplementary (non-reproducible from this issue alone; internal confidence-building, not a repro step): replaying our own saved PreToolUse payload against our own parent transcript (cut to the subagent tool call's timestamp) reproduced the exact deny reason text we observed live, while replaying against the subagent's own transcript at the same point allowed it. This relies on our private hook logs/setup, but corroborates that the parent transcript is genuinely what the hook received and read.
Expected behavior
transcript_path in a PreToolUse (and presumably other hook events) payload emitted for a subagent tool call should point at that subagent's own transcript file (e.g. .../subagents/agent-<agent_id>.jsonl), consistent with the agent_id field already present in the same payload.
If pointing at the subagent transcript is not feasible, the payload/hooks documentation should explicitly state that transcript_path is always the main session's transcript and that hook authors must derive the subagent transcript path themselves from agent_id (and document the expected file layout/naming contract for doing so).
Related issues (searched; none reports this exact defect)
- #14859 / #16424 — subagent hook events share the parent's
session_id; same root-cause family seen from thesession_idangle, filed as feature requests for agent context. This issue pins the concretetranscript_pathconsequence to the payload-builder code path. - #7881 — SubagentStop can't identify which subagent finished (shared session ids); doesn't establish that
transcript_pathis the parent's file. - #11396 (closed) — stale/unflushed transcript during a subagent run: a timing bug, not wrong-file.
- #9188 / #8710 (closed) — wrong
transcript_pathafter--continue/ compaction: different triggers, similar symptom.
Workaround
Hooks that scan transcript_path to make allow/deny decisions can check payload.get("agent_id"): if present (i.e. running inside a subagent), fail open (skip the transcript-dependent check) rather than trusting transcript_path, since it does not reflect the subagent's actual activity.