Four crashes on Windows that only localized users ever hit

Your machine runs German Windows. You install Hermes, start it, and every new conversation kills itself with one line you cannot interpret: UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9'. Switch the system language to English and everything works — so you assume you were unlucky. Someone else is on Chinese Windows and watches their desktop log fill with UnicodeDecodeError: 'gbk' codec can't decode; a third person notices that when the agent writes C:\...\report.html, the file can be neither tagged nor previewed.
These four problems are unrelated in the code. What they share is the thing that made them survive so long: they only appear on a localized, non-English Windows, so they are almost never reproduced or reported from an English environment. All four were fixed in v0.21.5 (tag v2026.9.24, released September 24, 2026). Here is what was actually going on.
1. A time-zone name was crashing the system-prompt build
The error looked like this:
agent/system_prompt.py -> _zone_bits() -> now.strftime("%Z")
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' ...: surrogates not allowed
Root cause: Windows hands back zone names encoded in the ANSI code page, not as Unicode. On French Windows, Paris, Madrid (heure d'été) arrives as cp1252 bytes. When the process’s LC_CTYPE is UTF-8 (Python’s UTF-8 mode, or a component like Piper/espeak switching the process-wide locale from another thread), those bytes are decoded with surrogateescape, so tzname() ends up holding lone surrogates.
The real trouble is what happens next: datetime.strftime splices tzname() into the format string as UTF-8, so %Z raises inside strftime — before any string exists, which means the existing _sanitize_surrogates pass never gets a chance to clean anything.
And because the system prompt is rebuilt at every conversation start and every compaction, this is not a one-off glitch: it refuses every new conversation you open.
The fix (PR #120748): a small helper, hermes_time.safe_strftime(value, fmt), and every strftime site that renders locale text now calls it. It behaves in two modes:
- On a healthy locale the output is byte-identical to
value.strftime(fmt). That is a hard requirement, not a nicety — the system prompt has to stay stable or prompt caching breaks. Tests compare the two directly for naive datetimes, IANA zones and valid non-ASCII zone names. - When the text carries surrogates, and that text is only ASCII plus surrogate-escaped bytes, the bytes are recovered and decoded through the ANSI code page (
locale.getencoding()), which givesheure d'étéback as text. Anything undecodable falls back toU+FFFD, and the result is always safe to serialize to JSON.
The reporting locales were fr-FR, es-AR and de-DE.
2. Child-process decoding on Chinese Windows
The log looked like this, on repeat:
[gateway-crash] thread Thread-NNN (_readerthread) raised
UnicodeDecodeError: 'gbk' codec can't decode byte 0x85 in position 2: illegal multibyte sequence
Root cause: _readerthread is the reader thread subprocess.communicate() starts on Windows. When a capture passes text=True and no encoding=, subprocess decodes through subprocess._text_encoding(), which is locale.getencoding() — cp936/GBK on Chinese Windows. The children in question write UTF-8, so any byte that is illegal in GBK raises UnicodeDecodeError in that thread, which the serve process’s threading.excepthook then prints as a [gateway-crash] line.
The specific offender was hermes_cli/web_routers/files.py::_fs_git_branch, which runs git branch --show-current for /api/fs/default-cwd — an endpoint Desktop polls continuously. git writes UTF-8, including branch names and its own localized stderr (the Chinese “not a git repository” message, for instance), so on a GBK host every poll raised in the reader thread and the endpoint returned an empty branch.
Worth noting why it went unnoticed for so long: that call’s kwargs sit in a dict literal ("text": True), and the project’s line-based rule for matching text=True never matched them.
The fix (PR #120749): those captures are pinned to UTF-8 explicitly (encoding="utf-8" with errors="replace"). The tui_gateway/ spawns (slash worker, compute host, cli.exec, pdftoppm) already pinned UTF-8; this closes the in-process capture class.
One related detail: the hidden-console gateway launcher added in v0.21.0 made the symptom go away on the reporter’s machine, but the readers themselves were never fixed. A disappearing symptom is not a root cause, and this fix is the root cause.
3. Paths starting with C:\ could be neither tagged nor previewed
Symptom: the agent on Windows writes C:\Users\me\report.html or a UNC path \\server\share\..., and Desktop shows no artifact tag. When a tag did appear, the preview label came out as \Users\me\report.html; the renderer fallback joined the path onto the working directory and produced /repo/C:\....
Root cause: looksLikePath and isPreviewableTarget in fallback-model/targets.ts only accepted file://, /, ./ and ~/, so a Windows absolute path failed the “is this a path” test, toolPreviewTarget returned nothing and no tag appeared. On the renderer side, localPreviewTarget treated anything not starting with / as relative and joined it onto cwd. And previewName parsed C:\... with new URL(...), where c: reads as a URL scheme, so only the tail survived as a label.
The fix (PR #121074): a new isWindowsAbsolutePath in lib/path-compare.ts accepts a drive letter with either slash and a UNC \\ prefix; looksLikePath accepts those paths; isPreviewableTarget runs its HTML check on anything looksLikePath accepts, so C:\x.html is previewable and C:\x.txt is not, matching POSIX behavior; localPreviewTarget no longer joins Windows absolute paths onto cwd; previewName labels them by file name. A private copy of the same regex in the attachment-upload check now calls the shared function (a separate commit, no behavior change).
4. A 10-second probe was calling a cold start a failure
Symptom: on a Windows cold start, Desktop’s local backend answers HTTP, then the app reports a boot failure, tears the child down, and logs a cascade beginning with “Ignoring stale Hermes backend exit (1)”.
Root cause: on a Windows cold start the local backend’s event loop stalls for 12–28 seconds while the GIL is held importing gateway platform modules. The boot-time WebSocket probe’s fixed budget was 10 seconds (DEFAULT_CONNECT_TIMEOUT_MS), so it latched a healthy cold start as a failure and everything downstream followed.
The fix (PR #120750): for a backend this app spawned, the probe keeps waiting past the 10-second base budget for as long as the child process is alive, up to a 90-second cap — the same budget the port-announcement step already allows for a cold start. The progress signal is process liveness, not output recency, because the stall is silent: the process prints nothing until its loop recovers (web_server.py’s loop heartbeat only logs event loop stalled afterwards).
The two failure shapes stay distinct: a live loopback child that accepted the TCP connect but has not answered the upgrade is busy, so the probe waits; a refusal or auth rejection still arrives as an immediate error/close; a child that exits fails at the next 1-second check; a live backend that never answers fails at the cap and surfaces the existing boot-failure overlay, whose Retry reloads the window. Remote gateways, the “Test remote” button and host-backend attach keep the fixed 10-second budget.
Release status for these four
All four are in v0.21.5 (tag v2026.9.24, September 24, 2026), so upgrading to that version gets them — no need to track main. That is worth stating, because a batch of other fixes merged the same day after the tag was cut, and those are not in here yet.
What to check on your own install
If you have hit any of the above, this confirms it after upgrading:
hermes --version # expect v0.21.5 (2026.9.24)
- Zone-name crash: open a new conversation on a localized Windows — no more
surrogates not allowed, and the system prompt should stay stable (your prompt-cache hit rate should not shift). - Child decoding: open Desktop on Chinese Windows — no more continuous
[gateway-crash] ... UnicodeDecodeErrorlines. - Windows paths: ask the agent to write a
C:\...\report.html— the artifact tag should appear and preview; same for a UNC path. - Cold start: cold-start Desktop after clearing the gateway state — no boot-failure overlay and no “Ignoring stale Hermes backend exit” cascade.
All four sit inside that patch window with no release notes; the fuller list is on our v0.21.5 release page and in the v0.21.5 window self-check guide. The streaming-delivery fixes merged the same day, which are not in this version yet, are covered in the delivery sweep.
The short version
What these four share is also what makes them miserable for localized users: they almost never reproduce on an English system. A time-zone name, a child process’s encoding, a drive-letter path and an import stall during boot all only surface at the intersection of “non-English Windows” plus Windows itself. On a localized Windows install, upgrading to v0.21.5 should remove a lot of inexplicable failure.