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:
- 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
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")
}
}