Advanced Claude Code Hooks: Controlling Sub-Agent Behavior

Claude Code hooks are powerful automation tools that execute at specific points during AI coding sessions. While basic hooks can validate prompts or add context, advanced hooks can enforce sophisticated rules like preventing parallel sub-agents from running test suites that share database connections.

Understanding Claude Code Hooks

Hooks in Claude Code are automated scripts that intercept and control the AI's tool usage. They execute arbitrary shell commands at specific lifecycle events, enabling you to:

  • Validate tool usage before execution (PreToolUse)
  • Add context to user prompts (UserPromptSubmit)
  • Clean up resources when sessions end (SessionEnd)
  • Inject environment data at session start (SessionStart)
  • Control permissions for file operations

The most powerful hook type is PreToolUse, which runs before any tool executes and can approve, deny, or request user confirmation for the operation.

The Problem: Parallel Execution and Shared Resources

Claude Code's sub-agent system enables parallel task execution. Multiple agents can work simultaneously on different aspects of your codebase. This is excellent for productivity, but creates challenges when those tasks share resources.

Consider a PHP project with PHPUnit tests that use a SQLite database. The test suite isn't optimized for parallel execution because:

  • Database locks: SQLite allows only one writer at a time
  • Shared state: Tests may create or modify the same fixtures
  • Race conditions: Parallel execution causes unpredictable failures

When Claude spawns multiple sub-agents to handle complex refactoring tasks, each might independently decide to run the test suite. The result? Database lock conflicts, failed tests, and confused AI agents.

The Solution: Sub-Agent Detection and Control

We can solve this by creating a hook that detects when it's running in a sub-agent context and blocks test execution, while still allowing other QA tools like static analysis and code style checks.

The key insight is that sub-agents run as child processes of the main claude process. By examining the parent process ID (PPID), we can determine whether we're in the main session or a sub-agent.

Implementation: The PreToolUse Hook

Here's a complete Python hook that implements sub-agent detection and selective command blocking:

#!/usr/bin/env python3
"""
PreToolUse hook to prevent subagents from running tests.

Subagents can run allCS and allStatic, but NOT unit tests, PHPUnit, or Infection.
Tests cannot run in parallel due to database lock conflicts.

Detection: Subagents have the main 'claude' process as their parent (PPID).
"""

import json
import os
import re
import subprocess
import sys


def is_subagent() -> bool:
    """Check if running in subagent context by examining PPID."""
    try:
        ppid = os.getppid()
        # Get parent process command name
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        # If we can't determine, assume not subagent (fail open)
        return False


def is_test_command(command: str) -> bool:
    """Check if command is a test execution (not allowed in subagents)."""
    test_patterns = [
        r'\bphpunit\b',
        r'\bbin/qa\s+.*-t\s+unit\b',
        r'\bbin/qa\s+.*--type\s+unit\b',
        r'\binfection\b',
        r'\bvendor/bin/phpunit\b',
        r'\bphp\s+vendor/bin/phpunit\b',
        r'\.\/bin\/qa\s+.*-t\s+unit\b',
    ]

    return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)


def is_allowed_qa_command(command: str) -> bool:
    """Check if command is an allowed QA command (allCS or allStatic)."""
    allowed_patterns = [
        r'\bbin/qa\s+.*-t\s+allCs\b',
        r'\bbin/qa\s+.*--type\s+allCs\b',
        r'\bbin/qa\s+.*-t\s+allStatic\b',
        r'\bbin/qa\s+.*--type\s+allStatic\b',
        r'\.\/bin\/qa\s+.*-t\s+allCs\b',
        r'\.\/bin\/qa\s+.*-t\s+allStatic\b',
    ]

    return any(re.search(pattern, command, re.IGNORECASE) for pattern in allowed_patterns)


def main() -> int:
    """Main hook logic."""
    try:
        # Read hook payload from stdin
        payload = json.loads(sys.stdin.read())

        # Only check Bash tool invocations
        if payload.get('tool') != 'Bash':
            return 0

        # Check if we're in a subagent
        if not is_subagent():
            return 0  # Not a subagent, allow all commands

        # Get the command being executed
        command = payload.get('parameters', {}).get('command', '')

        # Allow QA commands that are explicitly allowed
        if is_allowed_qa_command(command):
            return 0

        # Block test commands in subagents
        if is_test_command(command):
            error_msg = {
                'error': 'Test execution blocked in subagent context',
                'reason': 'Tests cannot run in parallel due to database lock conflicts',
                'command': command,
                'allowed': 'Subagents can run: bin/qa -t allCs, bin/qa -t allStatic',
                'blocked': 'Blocked commands: phpunit, bin/qa -t unit, infection'
            }
            print(json.dumps(error_msg), file=sys.stderr)
            return 1  # Block the command

        return 0  # Allow all other commands

    except Exception as e:
        # Log error but don't block (fail open for safety)
        print(f"Hook error: {e}", file=sys.stderr)
        return 0


if __name__ == '__main__':
    sys.exit(main())

How It Works

1. Sub-Agent Detection

The is_subagent() function uses process inspection to determine context:

  • Gets the parent process ID using os.getppid()
  • Queries the parent's command name using ps
  • Returns True if the parent is the claude process
  • Fails open (returns False) on errors to avoid blocking legitimate operations

2. Command Pattern Matching

The hook uses regex patterns to categorize commands:

  • Test commands: PHPUnit, Infection, bin/qa -t unit
  • Allowed QA commands: bin/qa -t allCs, bin/qa -t allStatic
  • All other commands: Allowed without restriction

3. Selective Blocking

The hook implements a whitelist/blacklist strategy:

  • Main agent: All commands allowed
  • Sub-agents: Static analysis allowed, tests blocked
  • Error response: Structured JSON explaining the block

Configuration

To enable this hook, add it to your Claude Code settings file (~/.claude/settings.json or .claude/settings.json):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/prevent-subagent-tests.py",
            "timeout": 2000
          }
        ]
      }
    ]
  }
}

Make the script executable:

chmod +x prevent-subagent-tests.py

Real-World Benefits

Prevents Database Lock Conflicts

By blocking parallel test execution, you eliminate SQLite database lock errors that would otherwise cause test failures and confuse the AI agents.

Enables Parallel Static Analysis

Sub-agents can still run code style checks (allCs) and static analysis (allStatic) in parallel, since these tools don't share resources.

Clear Error Messages

When a sub-agent attempts to run tests, it receives a structured JSON response explaining why the operation was blocked and what commands are allowed.

Fail-Safe Design

The hook uses a "fail open" strategy. If it can't determine whether it's in a sub-agent, it allows the command. This prevents blocking legitimate operations due to hook errors.

Extending the Pattern

This technique applies to any shared resource scenario:

  • Database migrations: Prevent parallel schema changes
  • File system operations: Block concurrent writes to lock files
  • External services: Rate-limit API calls across sub-agents
  • Build artifacts: Prevent simultaneous builds that share directories

The core pattern remains the same: detect sub-agent context via PPID, match command patterns, and selectively allow or block operations based on resource constraints.

Best Practices

Use Specific Patterns

Make your regex patterns as specific as possible to avoid false positives. Use word boundaries (\b) and full command paths when appropriate.

Fail Open for Safety

When error handling, prefer allowing the operation over blocking it. A blocked legitimate operation is more frustrating than a rare race condition.

Provide Clear Feedback

Structure your error messages as JSON with fields explaining what was blocked, why, and what alternatives are available.

Test Both Contexts

Verify your hook works correctly in both main agent and sub-agent contexts. Use echo $$ and ps commands to understand the process hierarchy.

Keep Hooks Fast

Hooks execute on every tool use. Keep them lightweight. This implementation completes in milliseconds.

Conclusion

Claude Code hooks unlock powerful automation capabilities beyond simple validation. By leveraging process inspection and pattern matching, you can enforce sophisticated execution policies that adapt to context. This allows parallel execution where safe, and prevents it where resources are shared.

This sub-agent control pattern transforms a potential source of race conditions and lock conflicts into a well-orchestrated parallel execution system. The main agent coordinates test execution, while sub-agents handle static analysis in parallel, maximizing productivity without sacrificing reliability.

Whether you're managing database locks, preventing concurrent migrations, or rate-limiting external API calls, this pattern provides a robust foundation for resource-aware parallel execution control.