fix: resolve two bugs making interactive mode unusable with plugin ecosystems (#825)#830
Conversation
App.tsx used `logForDebugging()` in four call sites (XTVERSION async handlers, handleReadable/handleDataChunk stdin error-recovery branches) without importing it. When esbuild bundled this, the unresolved symbol collided with another identifier in scope; the bundler renamed most references to `logForDebugging2` but left the four in App.tsx pointing at the original name, which became undefined in the bundle. At runtime any modern terminal replying to the XTVERSION probe triggers an `unhandledRejection: logForDebugging is not defined`. Adding the missing import resolves the symbol before bundling, so the bundler emits a single consistent name for every call site. Refs Gitlawb#825
The conditional `if (!requestPrompt) child.stdin.end()` kept stdin open in interactive mode (where requestPrompt is always truthy while the REPL is mounted). Every plugin hook written against the Anthropic hook input contract reads stdin until EOF, so hooks blocked on the per-hook timeout (default 60s) on every user message — no HTTP request to the provider was made until every UserPromptSubmit hook had timed out. With ~10 plugins hooked to UserPromptSubmit (pipeline-orchestrator, superpowers, skill-advisor, episodic-memory, reflexion, etc.), a single prompt accumulated minutes of wait before any model call. Always closing stdin after the initial JSON payload restores the documented EOF-based contract. Verified locally: typical `oi` turnaround drops from ~60s to ~1s. Trade-off: the existing duplex-stdin path (hooks emitting prompt requests on stdout and receiving responses written back to stdin at hooks.ts:1237) is incompatible with an EOF contract by design and stops working with this change. Restoring that feature requires a separate IPC channel (named pipe, node IPC, or a second stream) rather than reusing the initial stdin; that refactor is out of scope for this fix. Given the blast radius of the current behaviour (every user with a plugin ecosystem sees unusable interactive mode), trading the rarely-used duplex path for the documented single-shot contract is the right short-term move. Closes Gitlawb#825
There was a problem hiding this comment.
Pull request overview
Fixes two regressions in interactive mode that impact users running openclaude with plugin ecosystems: a runtime ReferenceError in Ink UI code and hook execution hangs caused by never-closed stdin.
Changes:
- Import
logForDebugginginsrc/ink/components/App.tsxto prevent a runtimeReferenceError. - Always close hook child-process stdin after writing the initial JSON payload to restore the EOF-based hook input contract.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/utils/hooks.ts |
Unconditionally closes hook stdin after writing initial payload to prevent interactive-mode hangs with EOF-reading hooks. |
src/ink/components/App.tsx |
Adds missing logForDebugging import to avoid runtime failures in XTVERSION / stdin recovery paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // future prompt-response on the same channel caused every UserPromptSubmit | ||
| // hook to block for the full per-hook timeout (default 60s) on every user | ||
| // message in interactive mode, since requestPrompt is truthy whenever the | ||
| // REPL is mounted. See issue #825 for the full analysis. | ||
| child.stdin.end() |
There was a problem hiding this comment.
Now that stdin is always closed via child.stdin.end(), the existing prompt-request implementation (which later writes prompt responses back to child.stdin when requestPrompt is set) will hit write-after-end/EPIPE behavior and won’t be able to deliver responses.
If dropping duplex prompt-response is intentional, consider explicitly disabling that path (stop parsing prompt requests and emit a clear log/warning) and update the stdin error-handler comment/logic that still says stdin stays open for prompt responses. If it should remain supported, it needs a separate IPC channel/stdio stream.
gnanam1990
left a comment
There was a problem hiding this comment.
Thanks for the precise diagnosis — both root causes (esbuild name collision + requestPrompt-truthy stdin leak) are well-explained, and the trade-off on the duplex pipe is documented honestly. Surgical fix for #825. LGTM 🚀
|
LGTM |
Summary
This PR fixes the two bugs I documented in #825. Together they make
openclaudeinteractive mode effectively unusable for any user with a non-trivial plugin ecosystem installed — which is the stated target audience of the project. Non-interactive mode (openclaude -p "...") is unaffected by both.Each bug is addressed in its own commit so they can be reviewed, cherry-picked, or reverted independently:
fix(ink): import logForDebugging in App.tsx to prevent ReferenceErrorfix(hooks): always close stdin after initial hook payloadBug 1 —
ReferenceError: logForDebugging is not definedsrc/ink/components/App.tsxcallslogForDebugging()in four places (XTVERSION async handlers at lines 266/268,handleReadableandhandleDataChunkstdin re-attach branches at lines 373/394) but never imports the function.In the bundled
dist/cli.mjs(v0.6.0), esbuild tried to resolve the undefined identifier from the surrounding scope and ended up with a name collision: most of the 2,400+ call sites throughout the bundle were renamed tologForDebugging2, but the four inApp.tsxwere left pointing at the original name, which is undefined at runtime. Any modern terminal replying to the XTVERSION probe (CSI > 0 q), or any stdin error-recovery path, triggers:Fix: add the missing import from
../../utils/debug.js. After this change, the bundler emits a single consistent name (logForDebugging) for every call site — the2suffix disappears entirely from the bundle, which is empirical evidence that it only existed to work around the unresolved reference.Diff: 1 line added.
Bug 2 —
UserPromptSubmithooks hang for up to 60 s on every promptIn
src/utils/hooks.ts:1352-1354, the initial hook-payload write closes stdin only whenrequestPromptis falsy:requestPromptis auseCallbackbound to the REPL (src/screens/REPL.tsx:2556), so in interactive mode it is always truthy. Stdin therefore never closes. Every plugin hook written against the documented Anthropic hook input contract reads stdin until EOF — hooks block on their per-hook timeout (default 60 s) while the prompt pipeline waits. The provider is never called during that window;~/openclaude-debug.logshows zeroFETCHevents until the timeouts fire.With a typical plugin setup (
pipeline-orchestrator,superpowers/superpowers-dev,skill-advisor,episodic-memory,reflexion,remember, and friends — ~10UserPromptSubmithooks), this serializes into minutes of wait per user message.Fix: always close stdin after writing the initial JSON payload, restoring the documented EOF-based contract. I've added a comment in the source explaining the rationale and linking back to #825.
Diff: 4 lines removed, 9 lines added (the bulk is the explanatory comment).
The code at
hooks.ts:1237writes a second JSON payload into the hook's stdin as a response to a prompt request — a duplex-stdin pattern (hook emits prompt requests on stdout, host writes responses back to stdin on the same stream). This PR breaks that path. An EOF-terminated single-shot stream and a duplex RPC channel are incompatible by design on the same pipe; you can have one or the other.The right long-term solution is a separate IPC channel for prompt-response — a named pipe,
child.send(Node IPC), or an extra stdio stream. That refactor is larger than this fix and belongs in its own discussion.Given that:
I think the trade-off is worth making now while a proper IPC channel is designed. If you'd prefer to preserve the duplex path at the cost of keeping every Anthropic-spec plugin broken, I'm happy to rework this PR — please flag and I'll iterate. An alternative I considered but didn't implement here: an opt-in config flag on the hook (e.g.
interactivePrompt: true) that keeps stdin open for hooks that declare they need it, while defaulting to the EOF contract for everyone else.Verification
bun install && bun run build— clean build.bun run smoke— passes (0.6.0 (Open Claude)).logForDebugging2is absent from the regenerateddist/cli.mjs(all call sites unified tologForDebugging).child.stdin.end()call sites inhooks.ts→dist/cli.mjs(lines 502030 and 502168 in the regenerated bundle) are now unconditional.dist/cli.mjsbefore opening this PR. Typicaloiturnaround dropped from ~60 s (hook timeouts) to ~1 s (actual API latency). All plugin hooks that return JSON on the initial stdin payload now complete correctly and the model responds promptly.Scope / not-in-this-PR
execCommandHook's stdin behaviour but didn't find one in-tree (the existing*.hooks.test.tscoversconversationRecovery, which is a different domain). Happy to add one in a follow-up PR if you point me at the pattern you'd like.dist/— that's generated output.Environment where this was reproduced and verified
@gitlawb/openclaude, installed vianpm -g)Closes #825