8.3 KiB
Agent Hook Stdin Lifecycle
Problem
Orca installs agent hooks in global agent configuration, so a managed hook can run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary terminal with no Orca environment. Hook runners write an event payload to the child process's stdin after spawn.
The generated scripts currently inspect Orca environment variables before they
read stdin. When a guard exits first, the hook runner can still be writing to a
pipe whose reader has closed. POSIX reports EPIPE; Windows reports
ERROR_BROKEN_PIPE. The same failure exists when a managed launcher finds that
its script was removed or is no longer executable.
This is an ownership bug rather than an agent-specific parsing bug. Every process that accepts a hook payload owns the read end until it reaches EOF, including processes that decide the event is irrelevant.
Goals
- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows, WSL, and SSH hosts.
- Encode stdin ownership once per platform instead of copying ad hoc drains into every early-exit branch.
- Preserve payload bytes, hook output, transport timeouts, exit-code behavior, and provider-specific event metadata on the forwarding path.
- Cover every generated script and missing-script launcher with behavioral regression tests.
Non-goals
- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP payloads, or event selection.
- Do not make hook payloads unbounded; existing agent and config timeouts remain the outer lifecycle bound.
- Do not suppress failures from a script that exists and actually runs. Only a deliberate no-op path exits successfully after consuming stdin.
- Do not change plugin-based integrations that do not receive piped hook stdin.
Contract
- A generated hook script consumes its input exactly once.
- No whole-script success exit may occur before that consumption completes.
- A launcher that declines to start a missing, unreadable, or non-executable managed script becomes the stdin owner and drains to EOF before returning success.
- A launcher propagates the exit code of a managed script that was started.
- Output required by an agent protocol may be emitted before stdin is read, but the process must still retain the read end until EOF.
- Drain commands are platform-qualified where the platform searches the working directory implicitly.
Design
POSIX generated scripts
Capture stdin near the start of the generated script, after any protocol output that must be immediate and before endpoint refresh, environment guards, or provider-specific skips:
payload=$(cat)
if [ -z "$payload" ]; then
exit 0
fi
Antigravity is the one semantic exception: events without payload still post an
empty object, so its shared capture policy maps empty input to {} instead of
exiting. Claude's Devin-import skip happens after capture. Command Code captures
before ancestor/endpoint recovery so its comparatively expensive discovery
cannot leave the writer blocked.
The common payload-capture fragments live in
src/main/agent-hooks/hook-stdin-contract.ts. Templates choose the required or
empty-object policy instead of spelling the lifecycle independently.
Windows batch generated scripts
Batch scripts stream stdin directly into system curl.exe; buffering arbitrary
JSON in an environment variable would corrupt metacharacters and hit size
limits. Their posting path therefore remains streaming.
All environment guard failures jump to one epilogue:
if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin
...
exit /b 0
:orca_agent_hook_drain_stdin
"%SystemRoot%\System32\more.com" >nul 2>nul
exit /b 0
more.com is qualified because Windows searches the current working directory
for executables. Shared guard and epilogue builders keep labels and commands
identical across templates. Command Code's existing endpoint-discovery labels
remain subroutines and the drain epilogue is placed after them.
PowerShell generated scripts
PowerShell captures with [Console]::In.ReadToEnd() before endpoint and
environment guards. Copilot then parses the captured value only on the posting
path. This mirrors POSIX ownership without starting an additional process.
Managed launchers
- POSIX
/bin/shlaunchers require a regular, readable, executable file. The rejected path drains withcat; the started-script branch keeps propagating status. - Encoded PowerShell launchers use
Test-Path -PathType Leaf. A missing script calls[Console]::In.ReadToEnd()and exits zero; an existing script preserves$LASTEXITCODE. - Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths
and directories, using the same qualified
more.comdrain for both. - Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
to execute the
.cmddirectly rather than interpreting it as shell source. - Agent-specific direct launchers, including Antigravity event wrappers and Copilot's PowerShell-file command, adopt the same missing-file behavior.
Data Flow
hook runner spawns command
-> runner writes payload and closes stdin
-> launcher starts managed script
-> script consumes payload
-> refreshes endpoint / evaluates guards
-> posts or exits zero
-> OR launcher cannot start script
-> launcher drains payload
-> exits zero
Local, WSL, and SSH installs serialize the same POSIX template. Windows local installs use the batch or PowerShell template. No host assumes another host's path syntax or shell.
Failure Semantics
- Missing Orca environment: consume input, exit zero, emit only protocol-required output.
- Empty payload: consume EOF, then follow the agent's existing empty-event rule.
- Missing/unreadable/non-executable script: launcher consumes input and exits zero.
- Endpoint parse/read failure: preserve the existing fail-open behavior after stdin ownership has been satisfied.
- Existing script returns nonzero: propagate its status; do not drain again or disguise the script failure.
- Hook runner never closes stdin: the existing config-level timeout terminates the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that already required by normal payload parsing.
Verification
Add one cross-agent lifecycle suite rather than per-service string-position assertions.
- Generate every SSH-compatible POSIX managed script, spawn it with Orca environment removed, write a payload larger than pipe buffers, and assert: exit zero, no stdin error, and required protocol output remains valid.
- Exercise the Claude Devin skip independently because it is a second no-op condition before endpoint forwarding.
- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script launchers with a large payload and verify zero write errors.
- On Windows CI, install and execute every local batch/PowerShell managed script with missing Orca environment and a large payload.
- Keep structural assertions for shared batch guard/epilogue generation, but do not use substring placement as the primary regression gate.
- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and nonzero-exit propagation tests.
For a macOS smoke test, launch the real Electron app with an isolated HOME
and ORCA_DEV_USER_DATA_PATH, record the launch time, then run:
node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \
--home "$ISOLATED_HOME" \
--min-mtime "$LAUNCH_START_MS"
The verifier rejects stale files, then exercises the 12 POSIX scripts written by that Electron launch with a payload larger than pipe buffers. It covers successful no-ops, loopback forwarding without payload changes, required JSON stdout, Claude's Devin skip, and both branches of the installed missing-script launcher.
Rollout And Compatibility
The generated scripts are rewritten by existing install/update flows, so no
config migration is needed. Commands use POSIX sh primitives, Windows inbox
executables, and PowerShell features available on supported Windows releases.
No Git behavior or provider-specific review behavior changes.
The change should ship atomically across templates and launchers. A partial rollout would leave global hooks with platform- or agent-dependent pipe safety, which is the inconsistency this design removes.