terminalSequence from an async:true hook is parsed and then dropped
A hook declared "async": true that returns {"terminalSequence": "..."} has no effect. The sequence is parsed, schema-validated, and carried into the async_hook_response attachment, then dropped without ever being emitted.
This is narrower than the documented "doesn't read its stdout": the stdout is read, and other fields from it (systemMessage, hookSpecificOutput.additionalContext, metrics) are delivered. Only terminalSequence goes missing, which reads like an oversight rather than an intended limit.
Version: 2.1.251, Windows 11. Config-based async (i.e. "async": true in the hook config), not the runtime async-marker form.
Repro
One script registered on PreToolUse matcher Glob, printing an OSC 2 set-window-title sequence and logging its own runs. A second process polled the console title every 5 ms. Only the async field differed between the two phases.
{"hooks":{"PreToolUse":[{"matcher":"Glob","hooks":[
{"type":"command","command":"python shim.py","async":true}]}]}}
# shim.py
import sys, json
sys.stdin.read()
ESC, BEL = chr(27), chr(7) # OSC 2 = set window title
sys.stdout.write(json.dumps({"terminalSequence": ESC + "]2;PROBE" + BEL}))
| phase | invocations | title changed |
|---|---|---|
| "async": true | 3 | 0 |
| same script, async removed | 2 | 2, within ~28 ms |
The hook ran in both phases (it received a full hook payload on stdin — 568 bytes), so this is a stdout-handling difference, not a registration difference. Control: the poller did detect a title set by an unrelated process, so it was not blind.
Where it goes
Minified identifiers are from the 2.1.251 bundle and will differ across builds.
- The only function that writes an escape to the terminal writer has a single caller, which is invoked from four synchronous result paths (command-hook stdout, HTTP, MCP, callback) — e.g.
let{json:Ke,…}=I0e(We.stdout);…jie(Ke,O);. - A config-async hook returns
{stdout:"",stderr:"",output:"",status:0,backgrounded:!0}before stdout is collected, so that synchronous call receives an empty string and returns immediately. - The backgrounded stdout is collected later: the process is registered, polled, and on completion the first non-
asyncJSON line is parsed and wrapped in anasync_hook_responseattachment. terminalSequenceis in the hook-output schema, so it validates and is preserved in that attachment.- The
case "async_hook_response":renderer reads onlysystemMessageandhookSpecificOutput.additionalContext. The sequence is dropped there.
Suggested fix
Call the existing terminal-sequence emitter inside the async-response map, alongside the metrics call already there. The allowlist check (OSC 0/1/2/9/99/777 + BEL) lives inside that emitter, so async output would be validated on exactly the same path as sync output, with no new surface.
Why it matters
Desktop-notification hooks are the canonical case for async — the work is fire-and-forget and must not add latency to a turn. Today the only way to get a terminalSequence emitted is to make the hook synchronous, which imposes a process spawn on every occurrence of high-frequency events like Stop and Notification. That is a real cost: in the plugin where this was found, the nine events for which emitting a sequence is safe account for 38 registered handlers.
If async terminalSequence is intended not to work, documenting that under the terminalSequence field would be an acceptable resolution and would save the next person the bisect. The field's current description gives no hint that it is conditional on the hook being synchronous.
Related
- #58858 (closed) added
terminalSequenceto the hooks reference, but the reference does not mention any interaction withasync. - #24794 (closed, not planned) is a different defect in the same async-stdout parser — it is line-oriented and mis-parses pretty-printed JSON. The line-by-line scan is still present in 2.1.251, so that report may still be live.