terminalSequence from an async:true hook is parsed and then dropped

Status Open
Reported on v2.1.251
Maintainer reply None cached
Activity 0 comments · opened Aug 31, 2026

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-async JSON line is parsed and wrapped in an async_hook_response attachment.
  • terminalSequence is in the hook-output schema, so it validates and is preserved in that attachment.
  • The case "async_hook_response": renderer reads only systemMessage and hookSpecificOutput.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 terminalSequence to the hooks reference, but the reference does not mention any interaction with async.
  • #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.

View original on GitHub ↗