Skip to content

fix(subagents): break recursion — disarm user hooks for subagent sessions#53

Merged
amitpaz1 merged 1 commit into
mainfrom
fix/subagent-recursion
May 9, 2026
Merged

fix(subagents): break recursion — disarm user hooks for subagent sessions#53
amitpaz1 merged 1 commit into
mainfrom
fix/subagent-recursion

Conversation

@amitpaz1
Copy link
Copy Markdown
Collaborator

@amitpaz1 amitpaz1 commented May 9, 2026

Summary

Follow-up to #52. The cheap-subagent flag set isolates MCP and pins the model to Haiku/Sonnet, but does not isolate hooks — and that turns out to be the dominant cost path.

Each claude -p subagent is itself a Claude Code session, so the user's PostToolUse / Stop / SessionEnd hooks fire for the subagent's own tool uses. The lore-capture-* hooks then spawn nested capture-extracts for the subagent's session_id, which spawn more, etc.

Observed in production on a single user's machine:

  • 731 spawns in 60 minutes, 685 distinct session IDs
  • $34.29/hour on Haiku
  • Only one interactive claude process running

The 685-distinct-sessions number is what gives it away — none of those sessions exist in the user's interactive workflow; they're subagent spawns each opening their own Claude Code session, accumulating their own buffer.jsonl, and triggering their own hooks.

Fix — two layers of defense

  1. subagent_config.settings_body() now writes hooks as empty arrays for all four event names. --settings overrides the user's hooks for the subagent.
  2. New SubagentConfig.env_overrides() returns LORE_AUTO_SAVE=false and LORE_DREAM_AUTO=false. The hook scripts honor these as master kill switches and exit 0 immediately. All three spawn sites pass env={**os.environ, **cfg.env_overrides()} so the guard survives even if the parent Claude Code process has cached ~/.claude/settings.json.

Test plan

  • tests/test_subagent_config.py::TestSettings::test_hooks_are_empty_to_break_subagent_recursion
  • tests/test_subagent_config.py::TestEnvOverrides — shape + role parity
  • tests/services/test_graph_extraction.py::TestSpawnClaudeArgs — extended to assert env vars on subprocess.Popen
  • Full unit suite: pytest tests/ --ignore=tests/integration2749 passed, 0 failed
  • ruff check clean

🤖 Generated with Claude Code

…ions

Each ``claude -p`` capture-extract / dream / graph_extraction subagent
is itself a Claude Code session. The user's PostToolUse / Stop /
SessionEnd hooks therefore fire for the subagent's *own* tool uses.
Without a guard, this cascades:

  1. Active session does N tool uses → PostToolUse hook → spawns
     capture-extract subagent.
  2. Subagent's own tool uses (Read, Bash, Grep …) re-fire PostToolUse,
     which writes to *its* session_id buffer.jsonl.
  3. After ``LORE_CAPTURE_N`` entries, the hook spawns *another*
     capture-extract for the subagent — and so on.

Observed in production for one user: ~700 spawns/hour, ~$34/h on
Haiku, 685 distinct sessions hit in 60 minutes despite only one
interactive ``claude`` process running. ``--strict-mcp-config`` from
PR #52 isolates MCP but does not isolate hooks.

Two layers of defense:

  1. ``subagent_config.settings_body()`` now writes ``hooks`` as
     empty arrays. ``--settings`` overrides the user's hooks for
     the subagent's session.
  2. New ``SubagentConfig.env_overrides()`` returns
     ``LORE_AUTO_SAVE=false`` and ``LORE_DREAM_AUTO=false``. The
     hook scripts honor these as master kill switches and exit 0
     immediately. All three spawn sites now pass
     ``env={**os.environ, **cfg.env_overrides()}`` so the guard
     survives any caching of ``settings.json`` by the running
     parent Claude Code process.

3 new tests: hooks-empty in materialized settings, env_overrides
shape, env_overrides parity across roles. Existing
TestSpawnClaudeArgs extended to assert recursion-guard env vars
are passed to subprocess.Popen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amitpaz1 amitpaz1 merged commit 42202a0 into main May 9, 2026
6 checks passed
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