Summary
When the Copilot CLI spawns a hook subprocess and writes the hook's JSON payload to that subprocess's stdin, it does not appear to close (send EOF on) the stdin write-end for tool-use hooks (preToolUse, postToolUse). For sessionStart the write-end is closed and the subprocess sees EOF promptly. For the tool-use hooks the write-end stays open, so a hook that reads stdin to completion never observes end-of-input.
This directly breaks the stdin-reading pattern shown in the official docs (INPUT=$(cat)), which blocks until EOF, and it holds the hook subprocess's stdin pipe handle open so the process cannot exit on its own until the CLI eventually tears the pipe down.
Environment
github/copilot-cli v1.0.64; still present as of the latest release at time of writing (v1.0.68).
- Reproduced on macOS and Linux. The hook subprocess in our case is a Node script, but the mechanism (a read that waits for EOF) is language-agnostic.
Expected behavior
After the CLI writes a hook's JSON payload to the subprocess stdin, it closes the write-end (sends EOF) for all hook events alike: sessionStart, preToolUse, and postToolUse. So a hook that reads stdin to completion terminates deterministically.
Actual behavior
sessionStart: stdin write-end is closed → the hook sees EOF immediately. ✅
preToolUse / postToolUse: stdin write-end is left open → a hook reading to EOF never returns; its stdin pipe handle stays open and keeps the subprocess alive until the CLI's own timeout/teardown.
Reproduction
A minimal hook that reads stdin to EOF, per the documented $(cat) pattern:
#!/usr/bin/env bash
# hook.sh: reads the payload the way the docs show
INPUT=$(cat) # blocks here until EOF
echo "read ${#INPUT} bytes" >&2 # never prints for tool-use hooks
Wire it as a preToolUse/postToolUse hook, then trigger a tool call.
- Observed:
hook.sh never reaches the echo; the subprocess lingers.
- With the same hook wired as
sessionStart: it reads and prints immediately.
(A Node equivalent: for await (const chunk of process.stdin) { … } behaves identically after the last chunk: the async iterator never ends for tool-use hooks.)
Why this matters
- It breaks the CLI's own documented usage. The hooks how-to reads stdin via
INPUT=$(cat), and cat returns only on EOF — so the documented pattern deadlocks for exactly the hooks most people write (tool-use).
- It leaks the hook subprocess. The open stdin pipe is a live, ref'd handle that keeps the process alive; hooks that would otherwise exit in milliseconds hang until the CLI's timeout, and overlapping hooks stack.
- The stdin/EOF contract is undocumented. Neither the hooks reference nor the use-hooks how-to states whether stdin is closed after the payload. The docs only imply "read stdin" via the
$(cat) example.
Not a duplicate of
Suggested resolution
Either (preferably both):
- Close the hook subprocess's stdin write-end after writing the payload, for every hook event (match the
sessionStart behavior).
- Document the stdin contract explicitly in the hooks reference, whether stdin is closed after the payload, and the recommended read pattern.
Notes
Hook-side workaround: don't read to EOF, instead read until the JSON payload parses as complete, or release the pipe (stdin.pause() / stdin.unref()) once the payload is in hand. But that only works around a contract the CLI should guarantee.
Summary
When the Copilot CLI spawns a hook subprocess and writes the hook's JSON payload to that subprocess's stdin, it does not appear to close (send EOF on) the stdin write-end for tool-use hooks (
preToolUse,postToolUse). ForsessionStartthe write-end is closed and the subprocess sees EOF promptly. For the tool-use hooks the write-end stays open, so a hook that reads stdin to completion never observes end-of-input.This directly breaks the stdin-reading pattern shown in the official docs (
INPUT=$(cat)), which blocks until EOF, and it holds the hook subprocess's stdin pipe handle open so the process cannot exit on its own until the CLI eventually tears the pipe down.Environment
github/copilot-cliv1.0.64; still present as of the latest release at time of writing (v1.0.68).Expected behavior
After the CLI writes a hook's JSON payload to the subprocess stdin, it closes the write-end (sends EOF) for all hook events alike:
sessionStart,preToolUse, andpostToolUse. So a hook that reads stdin to completion terminates deterministically.Actual behavior
sessionStart: stdin write-end is closed → the hook sees EOF immediately. ✅preToolUse/postToolUse: stdin write-end is left open → a hook reading to EOF never returns; its stdin pipe handle stays open and keeps the subprocess alive until the CLI's own timeout/teardown.Reproduction
A minimal hook that reads stdin to EOF, per the documented
$(cat)pattern:Wire it as a
preToolUse/postToolUsehook, then trigger a tool call.hook.shnever reaches theecho; the subprocess lingers.sessionStart: it reads and prints immediately.(A Node equivalent:
for await (const chunk of process.stdin) { … }behaves identically after the last chunk: the async iterator never ends for tool-use hooks.)Why this matters
INPUT=$(cat), andcatreturns only on EOF — so the documented pattern deadlocks for exactly the hooks most people write (tool-use).$(cat)example.Not a duplicate of
preToolUsetimeout → allow under parallel calls): framed as lifecycle/timeout and leaves the subprocess running, but does not identify stdin-not-closed as the cause.Suggested resolution
Either (preferably both):
sessionStartbehavior).Notes
Hook-side workaround: don't read to EOF, instead read until the JSON payload parses as complete, or release the pipe (
stdin.pause()/stdin.unref()) once the payload is in hand. But that only works around a contract the CLI should guarantee.