TUI freezes (keyboard dead, process alive) when SessionStart hook systemMessage contains block characters U+2588/U+2591 - Windows
Environment
- Claude Code: 2.1.198
- OS: Windows 11 Home (build 26200)
- Terminal: VS Code integrated terminal (xterm.js + ConPTY) — not yet tested in standalone Windows Terminal
- Hook runtime: Windows PowerShell 5.1 (
powershell -NoProfile -File ...)
Summary
A SessionStart hook that emits systemMessage containing the block characters █ (U+2588) or ░ (U+2591) freezes the TUI right after startup: the message may render, but the keyboard goes completely dead while the claude.exe process stays alive. The hook itself finishes normally (verified via a marker file the hook writes as its last step — the marker is always present, so the hang happens on the TUI side after consuming the hook output).
The same systemMessage with those two characters replaced by ASCII (# / -) works flawlessly. Everything else — Thai text, emoji (including surrogate pairs), multi-line content — is fine.
Steps to reproduce
- On Windows, add a
SessionStarthook that outputs JSON with asystemMessagecontaining block characters, e.g. a progress bar:
``json``
{
"systemMessage": "projects:\n ██████░░░░ 63.5% SmartBoiler\n ███░░░░░░░ 39.6% renovation",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "test"
}
}
(In our setup this is produced by a PowerShell script writing UTF-8 (no BOM) to raw stdout via StreamWriter over [Console]::OpenStandardOutput(), single-line ConvertTo-Json -Compress.)
- Start
claudeinteractively in VS Code's integrated terminal. - After the systemMessage appears, try typing.
Expected: normal input.
Actual: keyboard input is dead; the session never recovers; the process keeps running. (Reproduced 2/2 on our machine; historically it looked intermittent, which sent us down the wrong path for a while.)
Isolation evidence (content bisect)
We ran a ladder of systemMessage payloads through the same hook, several fresh-terminal rounds each, and judged pass/fail by keyboard responsiveness:
| Payload | Differs from real payload by | Result |
|---|---|---|
| Single-line plain ASCII | everything | ✅ pass 4/4 |
| 8-line ASCII | isolates multi-line/length | ✅ pass 4/4 |
| Single-line Thai | isolates non-ASCII/wide chars | ✅ pass 3/3 |
| Real payload with █░ → #- | only the block chars | ✅ pass 5/5 |
| Real payload (contains █░, plus Thai + emoji) | — | 💥 freeze 2/2 |
The only variable separating the passing and freezing arms is U+2588/U+2591. Notably, our project-mode payloads (Thai + emoji ⚠/🔄, no block chars) never froze — which originally masqueraded as a mode-dependent bug.
Possibly-related observation
After force-killing a frozen session, the leftover terminal shows the TUI had unrestored state: focus-reporting stays on (ESC[I/ESC[O appear on every focus change) and what looks like a pending DSR-style reply (ESC[9999999997;1n) arrives as input. Speculating: the renderer may issue a terminal query while measuring/laying out the ambiguous-width block characters and block forever waiting for the reply. Take with salt — we only see the outside of this.
Workaround
Sanitize the message before emitting from the hook: █ → #, ░ → -. With that in place, systemMessage works reliably in both our project and workspace hooks.
---
Thanks for Claude Code — the hook system is genuinely great, which is exactly why we keep stuffing dashboards into it. 🙏
P.S. Filing this cost us an afternoon of very scientific "open terminal, watch it freeze, close terminal" — roughly 16 test sessions on a Max subscription. If bug bounties are ever paid out in tokens, consider this a humble application. 😄 (Kidding. Mostly.)