# hooks: extend stdin-EOF fix to the readers that bypass the shared helper

`3054b0c`→[main](/content/gh/entireio/cli/commits/main/index.html)·
?
Karthik Rameshkumar·4d ago·5 files·+55 added/-37 removed

Self-review found the first commit only fixed agents that route through ReadAndParseHookInput. Four readers had their own io.ReadAll(stdin) and so kept the #1398 hang: copilot (readHookEnvelope), pi and external (ParseHookEvent), and the claude-code post-todo path (parseSubagentCheckpointHookInput).

Extract the guard + streaming decode into a shared primitive (ReadHookInputRaw) plus StdinLooksInteractive, and wire the bypassers to it:
- ReadAndParseHookInput now builds on ReadHookInputRaw.
- copilot uses ReadHookInputRaw (its parseHookEnvelope needs the raw bytes for key-name fallbacks).
- pi uses ReadAndParseHookInput[piHookPayload].
- claude-code post-todo uses ReadAndParseHookInput.
- external forwards raw stdin bytes verbatim to its subprocess (may be empty/non-JSON), so it keeps io.ReadAll but adds the interactive-terminal guard; the piped-held-open case for external plugins is documented as a residual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

01KXDTPJM9G1NDPF654CY8NYCDView transcript

## Changes

5

- cmd/entire/cli

- agent

- copilotcli

- Mlifecycle.go+5/-3

- Mevent.go+30/-5

- external

- Mexternal.go+9

- pi

- Mlifecycle.go+5/-11

- Mhooks.go+6/-18

```go
168 unmodified lines

func (c *CopilotCLIAgent) readHookEnvelope(stdin io.Reader) (*hookEnvelope, error) {
    data, err := io.ReadAll(stdin)
    raw, err := agent.ReadHookInputRaw(stdin)
    if err != nil {
        return nil, fmt.Errorf("failed to read hook input: %w", err)
    }
    return parseHookEnvelope(data)
}

// resolveTranscriptRef computes the transcript path from the session ID.
```

```go
// json.Decoder returns as soon as one complete JSON value has been read,
// independent of when — or whether — stdin is closed.
func ReadAndParseHookInput[T any](stdin io.Reader) (*T, error) {
    raw, err := ReadHookInputRaw(stdin)
    if err != nil {
        return nil, err
    }
    var result T
    if err := json.Unmarshal(raw, &result); err != nil {
        return nil, fmt.Errorf("failed to parse hook input: %w", err)
    }
    return &result, nil
}

// ReadHookInputRaw returns the raw bytes of a single JSON hook payload read from
// stdin, without waiting for EOF.
func ReadHookInputRaw(stdin io.Reader) (json.RawMessage, error) {
    if f, ok := stdin.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
        if StdinLooksInteractive(stdin) {
            return nil, errors.New("empty hook input")
        }
    }

var result T
    if err := json.NewDecoder(stdin).Decode(&result); err != nil {
        return &result, nil
    }
}

// StdinLooksInteractive reports whether r is an interactive terminal, i.e. no
// piped hook payload is on its way.
func StdinLooksInteractive(r io.Reader) bool {
    f, ok := r.(*os.File)
    return ok && term.IsTerminal(int(f.Fd()))
}
```

```go
func (e *Agent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
    const maxParseHookBytes = 10 * 1024 * 1024 // 10 MB
    if agent.StdinLooksInteractive(stdin) {
        return nil, errors.New("parse-hook: no hook input on stdin")
    }
    data, err := io.ReadAll(io.LimitReader(stdin, maxParseHookBytes))
    if err != nil {
        return nil, fmt.Errorf("parse-hook: read stdin: %w", err)
    }
}
```

```go
// ParseHookEvent translates a Pi hook invocation into a normalised lifecycle
// event. Implements agent.HookSupport.
func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
    data, err := io.ReadAll(stdin)
    parsed, err := agent.ReadAndParseHookInput[piHookPayload](stdin)
    if err != nil {
        return nil, fmt.Errorf("read pi hook input: %w", err)
    }
    if len(data) == 0 {
        return nil, errors.New("empty pi hook input")
    }
}
```

```go
// parseSubagentCheckpointHookInput parses PostToolUse hook input for subagent checkpoints
func parseSubagentCheckpointHookInput(r io.Reader) (*SubagentCheckpointHookInput, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return nil, fmt.Errorf("failed to read input: %w", err)
    }
    if len(data) == 0 {
        return nil, errors.New("empty input")
    }
}
```
