Any function-hook tool.call on Bash breaks Agent isolation: "worktree" — every Bash call refused with "isolation context for this agent was lost"
What's Wrong?
When a function-hooks plugin (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) registers any tool.call hook on Bash, a subagent spawned with Agent(isolation: "worktree") has every Bash call refused, including pwd and true:
The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (<repo>/.claude/worktrees/agent-xxx). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.
Retrying never helps (6 retries observed, all refused). Read/Edit and MCP tools keep working, only Bash is blocked. The hook itself can be a pure passthrough next(e) with no logic: the mere registration of a Bash tool.call hook triggers the loss (tengu_agent_worktree_cwd_escape_blocked / context_lost).
Bisection (all runs headless, claude -p, --output-format json, prompt asking the main agent to spawn one isolation: "worktree" general-purpose subagent that runs pwd once):
| Configuration | Subagent pwd |
| --- | --- |
| Project plugin with Bash hooks active | refused |
| Same, --settings '{"env":{"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS":"0"}}' | OK |
| Fresh git init repo, --plugin-dir minimal plugin with only a session.start hook | OK, prints the worktree path |
| Fresh git init repo, --plugin-dir minimal plugin with only a Bash tool.call passthrough | refused |
Side effect worth noting: if the blocked subagent calls EnterWorktree(path: <its worktree>) to recover, the parent session gets switched into that worktree (its own git commands are then refused outside that directory until ExitWorktree).
What Should Happen?
A passthrough function hook on Bash should not change where the command runs; the subagent's worktree cwd override should survive the hook chain (next(e)), and pwd should print <repo>/.claude/worktrees/agent-xxx.
Error Messages/Logs
The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (/path/to/repo/.claude/worktrees/agent-a0bafd2e8cb4295b0). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.
Steps to Reproduce
mkdir -p /tmp/repro/repo /tmp/repro/plug/.claude-plugin /tmp/repro/plug/hooks
cd /tmp/repro/repo && git init -q . && git commit -q --allow-empty -m init
printf '{"name":"plug","version":"0.0.1","description":"repro"}' > /tmp/repro/plug/.claude-plugin/plugin.json
echo '{"modules":["./h.ts"]}' > /tmp/repro/plug/hooks/hooks.json
cat > /tmp/repro/plug/hooks/h.ts <<'TS'
export const register = (on: any) => {
on("tool.call", { tool: "Bash" }, async ($: any, e: any, next: any) => next(e));
};
TS
P='Do not modify files. Spawn ONE subagent via the Agent tool with isolation: "worktree" and subagent_type general-purpose, with this prompt: "Run `pwd` once via Bash. Report verbatim the output or the full error message. Do not retry." Then paste the subagent report verbatim and stop.'
claude -p "$P" --output-format json --allowedTools "Agent,Bash" \
--plugin-dir /tmp/repro/plug \
--settings '{"env":{"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS":"1"}}' | jq -r .result
# → "The working-directory isolation context for this agent was lost …"
# Control: replace the hook by `on("session.start", ($, e, next) => next(e))` → pwd prints the worktree path.
Claude Model
Fable 5.1 (claude-fable-5-1) in the parent session; also reproduced with the default model in claude -p.
Is this a regression?
Unknown
Last Working Version
Unknown
Claude Code Version
2.1.263
Platform
macOS
Operating System
macOS (Darwin 25.6.0), Apple Silicon
Terminal/Shell
zsh 5.9 (Bash tool), fish as login shell
Additional Information
Function hooks are an early-access feature, so this may be a known limitation; if so, an explicit note in the function-hooks docs (or the error message) that Bash tool.call hooks are incompatible with isolation: "worktree" would save a lot of debugging. Our workaround is to create worktrees by hand (git worktree add … origin/main) and spawn agents without isolation, prefixing each command with cd <worktree> &&.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
I have this issue on 2.1.272 on Windows
Still reproduces on 2.1.287 (macOS, Apple Silicon), with the same one-line pass-through repro. A few more data points from bisecting it while building guard plugins:
on('tool.call', { tool: 'Edit' }, ($, e, next) => next(e))leaves the worktree subagent's Bash working, andpwdprints<repo>/.claude/worktrees/agent-…. An unfilteredon('tool.call', ($, e, next) => next(e))breaks it, as does{ tool: 'Bash' }.classic.PreToolUsedoesn't lose isolation, but its deny seemed to have no effect. A plugin hookingclassic.PreToolUse(pass-through) keeps the subagent'spwdin its worktree. A plugin returning{ deny: '…' }fromclassic.PreToolUsefor every Bash call fired (the debug log showsclassic.PreToolUse settled in 41.7ms), but the command still ran, in both the main loop and a subagent. So it doesn't work as a workaround for guard plugins. I may be misusing it; if so, the docs could say how a function hook should deny at that stage.$.session.cwd()gives the parent session's directory inside a worktree subagent's tool calls, not the worktree. So even without this bug, a guard plugin can't tell which tree a subagent is working in. The settings-hook input'scwd(andagent_id) does give the subagent's own worktree.PreToolUsecommand hook ("command": "node …/pretooluse.ts", importing the plugin's check functions), and keep only slash commands ($.command.register+command.run) in the function-hook plugin. A settings hook doesn't break isolation, and it sees each subagent's owncwd.Also reproduces on 2.1.287 (macOS, Apple Silicon). A few data points that may help scope the fix:
Impact is wider now that mods load by default. On 2.1.287 our mod's hooks fired with no
CLAUDE_CODE_ENABLE_FUNCTION_HOOKSset anywhere. So any installed plugin with a Bash-matched (or unfiltered)tool.callhook now silently breaksisolation: "worktree"subagents for every session on the machine, not just for people who opted in.A
tool.callhook that serves a plugin's own registered tool is safe. Tool registration needs atool.callhook to answer the call. One matched only to the plugin's own tool names left isolated Bash working every time:| Variant (
claude -p --model haiku, oneAgentcall withisolation: "worktree"runningpwd && git status --short) | Isolated Bash ||---|---|
| Plugin registers 2 tools at
session.start, serves them viaon('tool.call', { tool: [ownToolA, ownToolB] }, …), tool not called | ok 3/3 || Same, plugin tool called in the main session before the spawn | ok 3/3 |
| Same plugin +
on('tool.call', { tool: 'Bash' }, ($, e, next) => next(e))| refused 2/2 ("working-directory isolation context for this agent was lost") || Plugin uninstalled | ok 2/2 |
So the trigger seems to be specifically a
tool.callhook whose matcher covers Bash. Registering tools, serving them,session.start,turn.complete,agent.spawnand theclassic.*events (PreToolUse/PostToolUse/PostToolUseFailure/Stop/SubagentStart) all coexist with isolation in our runs.Workaround we use: never give a
tool.callhook a matcher that can match Bash. We observe Bash outcomes fromclassic.PostToolUse/classic.PostToolUseFailureinstead; both carryagent_idandcwd, and a failed command arrives asExit code N.