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

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

3054b0c→main· ? 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:

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

Sessions

01KXDTPJM9G1NDPF654CY8NYCDView transcript

Changes

5

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.
// 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()))
}
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)
    }
}
// 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")
    }
}
// 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")
    }
}