# Subagent Guard for session-resume - Research

**Researched:** 2026-03-30
**Domain:** Claude Code hooks, subagent detection, Rust process introspection
**Confidence:** HIGH

## Summary

The `session-resume` command runs as a `SessionStart` hook in `~/.claude/settings.json`. According to official Claude Code documentation, **SessionStart fires only for the main/top-level session, not for subagents** spawned via the Agent tool. Subagents have their own lifecycle events (`SubagentStart`/`SubagentStop`). This means the original problem description may be about **non-interactive `claude -p` invocations** (psyche wrappers, piped sessions) rather than true Agent-tool subagents.

Regardless of root cause, a guard is still valuable for two reasons: (1) `claude -p` sessions (used by psyche/spine) DO trigger SessionStart and should not receive resume XML, and (2) defensive coding against future Claude Code behavior changes. The best detection strategy is **reading the `session_id` and `source` fields from stdin JSON** that Claude Code passes to all hook commands, combined with a **stdin TTY check** as a fast heuristic.

**Primary recommendation:** Read the JSON stdin that Claude Code provides to SessionStart hooks. Parse it for `agent_type` (present when launched via `claude --agent`). For non-`--agent` subagent detection, use a stdin-is-not-a-TTY check as a secondary heuristic, but note that hooks NEVER have a TTY stdin (they always receive piped JSON). The most reliable approach is to check whether the **process tree contains nested `claude.exe` instances** (subagent = claude spawned by claude), which the codebase already has infrastructure for via `get_parent_pid()`.

## Key Findings

### 1. SessionStart Hook Does NOT Fire for Agent-tool Subagents

**Confidence: HIGH** (official docs)

The official Claude Code hooks documentation explicitly lists `SessionStart` and `SubagentStart` as separate events. SessionStart fires for the main session only. Subagents spawned via the Agent tool get `SubagentStart`/`SubagentStop` events instead. Global `settings.json` SessionStart hooks do NOT run when a subagent starts.

**Implication:** If users are seeing resume XML in subagent output, the cause is likely one of:
- `claude -p` invocations (psyche/spine wrappers) which start full sessions
- Agent teams (experimental, uses separate claude processes)
- Confusion about which sessions are receiving the output

### 2. Hook Stdin JSON Schema for SessionStart

**Confidence: HIGH** (official docs)

SessionStart hooks receive JSON on stdin:

```json
{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/working/dir",
  "hook_event_name": "SessionStart",
  "source": "startup",
  "model": "claude-sonnet-4-6"
}
```

Optional field when started with `claude --agent <name>`:
```json
{
  "agent_type": "agent-name"
}
```

**`agent_id` is NOT present in SessionStart input.** It only appears in SubagentStart/SubagentStop and tool-use events inside subagents.

### 3. Environment Variables Available

**Confidence: HIGH** (verified in current session + official docs)

| Variable | Value in this session | Relevance |
|----------|----------------------|-----------|
| `CLAUDECODE` | `1` | Present in Bash-tool shells, NOT in hooks |
| `CLAUDE_CODE_ENTRYPOINT` | `cli` | Present in current session; unclear if set in hooks |
| `CLAUDE_CODE_TEAM_NAME` | (not set) | Set on agent-team members |
| `CLAUDE_CODE_PLAN_MODE_REQUIRED` | (not set) | Set on teammates requiring plan approval |

**Critical note from docs:** `CLAUDECODE=1` is set in "shell environments Claude Code spawns (Bash tool, tmux sessions)" but explicitly **"Not set in hooks or status line commands."** This means the `session-resume` hook does NOT have `CLAUDECODE` in its environment.

No dedicated `CLAUDE_SUBAGENT` or `CLAUDE_AGENT` or `CLAUDE_PARENT_SESSION` environment variable exists. There is no env var specifically designed to distinguish subagent from top-level session in a hook context.

### 4. TTY Detection is NOT Useful for Hooks

**Confidence: HIGH** (verified + docs)

All hooks receive JSON input on stdin via a pipe. Even in an interactive top-level session, the hook process's stdin is NOT a TTY -- it's a pipe carrying the JSON payload. Therefore `atty::is(Stream::Stdin)` will return `false` for ALL hook invocations, making it useless as a subagent discriminator.

Similarly, stdout from hooks is captured by Claude Code (to process as `additionalContext`), so stdout is also piped, not a TTY.

### 5. Process Tree Detection (Existing Infrastructure)

**Confidence: HIGH** (code review)

The codebase already has `get_parent_pid()` in `src/common/types.rs` which walks the process ancestor chain looking for `claude.exe`. This finds the FIRST claude.exe ancestor.

For subagent detection, the question is: **does the ancestor chain contain TWO or more claude.exe processes?** A top-level session has: `claude.exe -> bash -> owl.exe`. A subagent session would have: `claude.exe (parent) -> claude.exe (subagent) -> bash -> owl.exe`. However, this only applies if SessionStart hooks DO fire in subagents (which the docs say they don't).

For `claude -p` piped sessions (psyche wrappers), the process tree would be: `claude.exe (parent session) -> bash (spawned by Bash tool) -> claude -p (child) -> bash -> owl.exe`. Here the hook's ancestor chain would have claude.exe appearing at least twice. But the existing `get_parent_pid()` stops at the FIRST claude.exe it finds, which would be the inner `claude -p` process -- the same as a normal top-level session.

### 6. The `source` Field Approach

**Confidence: HIGH** (official docs)

The `source` field in SessionStart JSON indicates how the session started:
- `"startup"` -- new session
- `"resume"` -- resumed session
- `"clear"` -- after `/clear`
- `"compact"` -- after context compaction

This doesn't directly indicate subagent vs top-level, but combined with `agent_type` presence, it's useful. If `agent_type` is present, the session was started with `claude --agent`.

### 7. Psyche/Spine Wrapper Detection

**Confidence: HIGH** (project knowledge)

Psyche wrappers run `claude -p` which starts a full Claude Code session. These sessions DO trigger SessionStart hooks. They are non-interactive but are NOT subagents in the Agent-tool sense.

Detection approaches for psyche `claude -p` sessions:
- **Command-line args of parent process:** The parent `claude` process would have `-p` in its arguments. This is detectable via `wmic`/`/proc` but adds complexity.
- **`agent_type` field:** If the psyche wrapper is launched via `claude --agent psyche-wrapper`, the `agent_type` field would be present.
- **Environment variable injection:** The simplest approach -- set a custom env var in the command that launches `claude -p` for psyche, and check for it in the hook.

### 8. SessionStart Hook Matcher Configuration

**Confidence: HIGH** (official docs)

SessionStart hooks support a `matcher` field with values: `"startup"`, `"resume"`, `"clear"`, `"compact"`. This filters based on session start SOURCE, not session TYPE. There is no matcher value to filter by "not a subagent" or "interactive only."

```json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [{ "type": "command", "command": "..." }]
      }
    ]
  }
}
```

The `if` field is NOT supported for SessionStart hooks (only for tool events).

## Architecture Patterns

### Recommended Approach: Stdin JSON Parsing

Since `session-resume` already runs as a hook and receives JSON on stdin, the cleanest approach is to **read and parse the stdin JSON** in the Rust binary. If `agent_type` is present, skip resume output. This handles the `claude --agent` case.

```rust
use std::io::{self, Read};
use serde::Deserialize;

#[derive(Deserialize)]
struct HookInput {
    session_id: Option<String>,
    source: Option<String>,
    agent_type: Option<String>,
    // agent_id is NOT present in SessionStart
}

fn should_skip_resume() -> bool {
    let mut input = String::new();
    // Read stdin (non-blocking -- data is already there from pipe)
    if io::stdin().read_to_string(&mut input).is_ok() {
        if let Ok(hook) = serde_json::from_str::<HookInput>(&input) {
            // Skip if launched via `claude --agent`
            if hook.agent_type.is_some() {
                return true;
            }
        }
    }
    false
}
```

### Alternative: Custom Environment Variable

For psyche/spine wrappers specifically (which use `claude -p`), the launch command can set a custom env var:

```bash
OWL_SKIP_RESUME=1 claude -p "..."
```

Then in Rust:
```rust
if std::env::var("OWL_SKIP_RESUME").is_ok() {
    return; // Skip resume
}
```

### Combined Strategy (Recommended)

```rust
pub fn run() {
    // Guard 1: Custom env var (for psyche/spine/scripted sessions)
    if std::env::var("OWL_SKIP_RESUME").is_ok() {
        return;
    }

    // Guard 2: Parse hook stdin JSON for agent_type
    if is_agent_session_from_stdin() {
        return;
    }

    // ... existing resume logic ...
}
```

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON parsing from stdin | Custom parser | `serde_json` (already in deps) | Already used throughout codebase |
| TTY detection | `atty` crate | Nothing -- hooks are NEVER TTYs | Would give false positives for all cases |
| Subagent detection via process tree | Double-claude walk | stdin JSON `agent_type` field | Process tree is unreliable and platform-specific |

## Common Pitfalls

### Pitfall 1: Assuming Hooks Have TTY Access
**What goes wrong:** Developer adds `atty::is(Stream::Stdin)` check, finds it always returns false, concludes all sessions are non-interactive.
**Why it happens:** Hooks receive JSON on stdin via pipe, even in interactive sessions.
**How to avoid:** Never use TTY detection for hook commands. Use the JSON input fields instead.

### Pitfall 2: Blocking Stdin Read in Hook
**What goes wrong:** `read_to_string` blocks if there is no EOF signal on stdin.
**Why it happens:** Depending on how Claude Code pipes the JSON, the read might hang.
**How to avoid:** Use a timeout or non-blocking read approach. In practice, Claude Code writes JSON and closes stdin, so `read_to_string` should complete quickly. Add a timeout as defensive measure.

### Pitfall 3: Conflating `claude -p` with Agent-tool Subagents
**What goes wrong:** Guard blocks resume for `claude -p` sessions but not for true subagents, or vice versa.
**Why it happens:** These are different mechanisms. `claude -p` starts a full session (SessionStart fires). Agent-tool subagents do NOT trigger SessionStart.
**How to avoid:** Be clear about which case you're solving. The documented problem (subagents getting resume XML) most likely refers to `claude -p` sessions, not Agent-tool subagents.

### Pitfall 4: Breaking the Hook Output Contract
**What goes wrong:** The hook reads stdin (consuming the JSON) but Claude Code expects to send it. Or the hook's stdout output format changes.
**Why it happens:** SessionStart hooks can output `additionalContext` JSON or plain text to stdout.
**How to avoid:** Reading stdin is fine -- it's provided FOR the hook to consume. Stdout output should remain plain text (as it currently is) or structured JSON with `additionalContext`.

## Edge Cases

### Psyche Wrappers (`claude -p`)
- These DO trigger SessionStart hooks
- They are non-interactive but NOT subagents
- `agent_type` will NOT be present (unless launched with `--agent`)
- **Best detection:** Custom env var set by the psyche launch command, OR checking the `source` field (will be `"startup"`)
- Current behavior: Already filtered by `parent_pid` matching in resume.rs -- psyche sessions have a different parent claude.exe PID than the interactive session that spawned them. **This may already work correctly.**

### Background Agent Tasks (`run_in_background: true`)
- These are true subagents and do NOT trigger SessionStart hooks
- No guard needed

### Agent Teams (Experimental)
- Each teammate runs as a separate Claude Code process
- Each MAY trigger SessionStart hooks
- `CLAUDE_CODE_TEAM_NAME` env var is set on teammates
- Can check: `if std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok() { return; }`

### Session Resume (`/resume` command)
- Triggers SessionStart with `source: "resume"`
- This IS a case where resume XML IS wanted
- Guard must NOT block this case

### Context Compaction
- Triggers SessionStart with `source: "compact"`
- Resume XML should probably NOT be re-emitted during compaction
- Consider adding a guard for `source: "compact"` as well

## Recommended Implementation

### Minimal Viable Guard (addresses documented problem)

1. **Parse stdin JSON** for `agent_type` field
2. **Check `CLAUDE_CODE_TEAM_NAME` env var** for agent team members
3. **Skip resume output** if either is present

### Extended Guard (covers all edge cases)

1. Parse stdin JSON:
   - Skip if `agent_type` is present (launched via `--agent`)
   - Skip if `source` is `"compact"` (re-emission during compaction is wasteful)
2. Check env vars:
   - Skip if `CLAUDE_CODE_TEAM_NAME` is set (agent team member)
   - Skip if `OWL_SKIP_RESUME` is set (explicit opt-out for scripted sessions)
3. All other cases: proceed with normal resume logic

### Implementation Notes for Rust

- `serde_json` is already a dependency
- Stdin reading should be done ONCE at the start of `run()`, before any filesystem access
- The JSON parse can fail silently (old Claude Code versions, or if stdin is empty) -- fall through to normal behavior
- No new crate dependencies needed

## Sources

### Primary (HIGH confidence)
- [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks) - SessionStart schema, event lifecycle, matcher configuration
- [Claude Code Subagents Documentation](https://code.claude.com/docs/en/sub-agents) - Subagent architecture, hook scoping
- [Claude Code Environment Variables](https://code.claude.com/docs/en/env-vars) - Complete env var reference, CLAUDECODE scope

### Secondary (MEDIUM confidence)
- Project source code: `src/owl/resume.rs`, `src/common/types.rs` - Existing architecture and patterns
- Live environment verification of `CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT` values

## Metadata

**Confidence breakdown:**
- Hook behavior (SessionStart vs SubagentStart): HIGH - official docs verified
- Stdin JSON schema: HIGH - official docs, multiple sources confirm
- TTY detection uselessness: HIGH - verified in live session + docs confirm pipe delivery
- Psyche edge case: MEDIUM - inferred from project knowledge, not directly tested
- Agent team detection: MEDIUM - env var documented but team feature is experimental

**Research date:** 2026-03-30
**Valid until:** 2026-04-30 (stable area, hooks API is established)
