Release notes

BUGFIX

Self-heal stops taking over your own script that just looks like a hook

You keep your own backup named usage-terse-mode-backup.py, and usage's self-heal treated it as one of its own hooks and silently rewrote it to the official command. The cause was a plain substring check against a marker like usage-terse-mode: any filename containing those characters counted. The removal path was fixed first, requiring a path separator, quote, whitespace, or string boundary around the marker and a trailing .py. The same precise check is now used at the four remaining call sites in the migration logic that decide whether to overwrite a hook's command.

BUGFIX

Plain English words are no longer read as Japanese or Korean

A language code is the short tag that says which language the interface should speak: ja for Japanese, ko for Korean. usage matched those with an unbounded prefix test, so jargon was read as Japanese and koala as Korean. Other forms were misread too: zh_TW@variant fell through to English because the part after @ was not stripped, and zh-HK did not accept further subtags the way zh-TW and zh-Hant did. A second pass found the same unbounded-prefix bug in zh-Hant and zh-Hans, letting a nonsense zh-Hantasy match as Traditional Chinese, and a missing zh-SG- prefix that dropped a legitimate zh-SG-x-private to English. This logic was duplicated across four files; all four now require a boundary after every prefix.

BUGFIX

A non-UTF-8 terminal no longer swallows the whole message

When a terminal uses a non-UTF-8 encoding such as cp950, usage's hooks wrote JSON without escaping, raised UnicodeEncodeError, and lost your entire message. Output is now forced to ASCII-safe JSON, with the write wrapped in try/except OSError. A second problem sat in the version marker: four readers used split and choked on a truncated line or invalid bytes, so a broken installed script was never recognized as needing reinstalling; they now use partition and also catch UnicodeDecodeError. And when a terse-mode sidecar's content had rotted, it was never rebuilt as long as the hook version was unchanged, because staleness was checked with a plain exists(); it is now checked by validating the sidecar's content.

BUGFIX

The resume prompt no longer vanishes over one small failure

When you resume a session, usage gives you a prompt saying where you left off, and that prompt used to disappear whole. _read_stdin_utf8() could raise OSError on a broken pipe and, unlike terse mode and the terse reminder, session resume did not catch it, so an I/O hiccup crashed the hook instead of exiting 0. _parse_session() caught OSError but not UnicodeDecodeError, so a session log with invalid UTF-8 bytes made the whole resume prompt vanish instead of falling through to the next older, valid log the way an empty log already did. Both are now caught, and the resume prompt still appears.

BUGFIX

A finished todo list gets cleared, and a lone punctuation reply does not count

Everything from last time was finished, yet the next resume prompt kept reporting that work. The resume hook only overwrote its stored todo list when the new one was non-empty, so the last TodoWrite call of a session — the one that marks everything complete, leaving an empty list — was ignored. A todo list is now cleared once every item on it is marked done. Three more fixes landed alongside it. Saving the health-check reminder's dedup state could raise OSError on a full disk or a permissions error, and that exception propagated far enough to swallow the main resume prompt; it is now scoped with contextlib.suppress(OSError). _has_structural_signal treated any punctuation anywhere in the text as a sign of real content, so short replies like ok. or /clear were kept as if they were substantive; it now requires alphanumeric characters on both sides of the mark. And git status's trailing slash on an untracked directory path made basename return an empty string, which was silently dropped, so a brand-new uncommitted folder never appeared in the uncommitted-work summary.

FEATURE

The quota-restored notification is back, and a first reading past the threshold warns

usage is supposed to tell you when depleted quota comes back, and that notification never fired. Any reading below 100% cleared the "was depleted" flag, regardless of how small the drop was, so by the time a genuine reset was detected — a single-step drop large enough to distinguish a real period rollover from noise — the flag had already been cleared by an earlier, tiny fluctuation. It is now cleared only by that same reset detection. Separately, a warning threshold could only be crossed relative to a previous reading, so quota that was already above the threshold on the very first observation — the app launching mid-session at 95%, for instance — produced no warning at all, unlike the depleted notification, which always fires on a first reading of 100%.

BUGFIX

One registry read failure no longer takes down the tray menu

On Windows, clicking the menu bar icon could leave the whole tray menu broken. Only one row was actually at fault: to draw the "launch at login" checkbox, the app reads the registry, the database where Windows keeps system and program settings, and its is_enabled() caught only FileNotFoundError. A registry permission error therefore propagated out and could break the whole tray menu instead of just that one row; it now catches any OSError and reports "not enabled." The same batch removed a dead settings key, StartDelay: launchd.plist(5) does not recognize it, so it had no effect and was doing nothing since it was added.

FEATURE

Terse mode's plain-language rule now covers text pasted from a subagent

Terse mode asks for plain language, but the rule used to govern only sentences Claude wrote itself. When a subagent's report or a tool's raw output was quoted directly, the mode's own prompt gave no instruction for it, so jargon inside went unexplained. A line was added after the existing "explain a term on first use" rule, in all five languages.

Original changelog

Original changelogSelf-heal stops taking over your own script that just looks like a hook
**Self-heal no longer takes over a user's own script that merely shares part of a hook's filename.** Both the hook-removal path and the migration path that keeps an installed hook's command current used a plain substring check against markers like `usage-terse-mode`, so a user's own script — say, a backup named `usage-terse-mode-backup.py` — matched and was silently rewritten to the official command. The removal path was fixed first, requiring a path separator, quote, whitespace, or string boundary around the marker and a trailing `.py`; the same precise check is now used at the four remaining call sites in the migration logic that decide whether to overwrite a hook's command.
Original changelogPlain English words are no longer read as Japanese or Korean
**Language detection no longer misreads plain English words as Japanese or Korean, or a made-up Chinese-script tag as a real one.** The `_normalize_lang` logic — duplicated across four files — matched `ja`/`ko` with an unbounded `startswith`, so `jargon` and `koala` were read as Japanese and Korean; a POSIX `@modifier` suffix wasn't stripped, so `zh_TW@variant` fell through to English; and `zh-HK` didn't accept further BCP 47 subtags the way `zh-TW`/`zh-Hant` did. A second pass found the same unbounded-prefix bug in `zh-Hant`/`zh-Hans` (letting a nonsense `zh-Hantasy` match as Traditional Chinese) and a missing `zh-SG-` prefix (dropping a legitimate `zh-SG-x-private` tag to English). All four duplicated copies now require a boundary after every prefix.
Original changelogA non-UTF-8 terminal no longer swallows the whole message
**A non-UTF-8 terminal no longer crashes terse mode or session resume, and a truncated or corrupted version marker no longer wedges self-heal.** The SessionStart/UserPromptSubmit hooks wrote JSON without `ensure_ascii`, so a terminal using an encoding like cp950 raised `UnicodeEncodeError` and lost the entire message; output is now forced to ASCII-safe JSON with the write wrapped in `try`/`except OSError`. The four version-marker readers used `split` and choked on a truncated line or invalid bytes, which meant a broken installed script was never recognized as needing reinstalling; they now use `partition` and also catch `UnicodeDecodeError`. Separately, a stale terse-mode sidecar whose content had rotted was never rebuilt as long as the hook version hadn't changed, because staleness was checked with a plain `exists()`; it's now checked by validating the sidecar's content.
Original changelogThe resume prompt no longer vanishes over one small failure
**Session resume's stdin and stdout failures are handled the same way its sibling hooks already were, and a corrupted newest session log falls back to an older one instead of going silent.** `_read_stdin_utf8()` could raise `OSError` on a broken pipe, and unlike terse mode and the terse reminder, session resume didn't catch it — an I/O hiccup crashed the hook instead of exiting 0. Separately, `_parse_session()` caught `OSError` but not `UnicodeDecodeError`, so a session log with invalid UTF-8 bytes made the whole resume prompt disappear instead of falling through to the next older, valid log the way an empty log already did.
Original changelogA finished todo list gets cleared, and a lone punctuation reply does not count
**A todo list is now cleared once every item on it is marked done.** The resume hook only overwrote its stored todo list when the new one was non-empty, so the last `TodoWrite` call of a session — the one that marks everything complete, leaving an empty list — was ignored, and the next session's resume prompt kept reporting work that was already finished.
**The health-check reminder's own state write no longer takes the whole resume prompt down with it, a bare punctuation reply is no longer read as a real request, and a newly created but uncommitted directory now shows up in the "not saved yet" list.** Saving the health-check reminder's dedup state could raise `OSError` (a full disk, a permissions error) and that exception propagated out far enough to swallow the main resume prompt along with it; it's now scoped with `contextlib.suppress(OSError)`. Separately, `_has_structural_signal` treated any punctuation anywhere in the text as a sign of real content, so short replies like `ok.` or `/clear` were kept as if they were substantive; it now requires alphanumeric characters on both sides of the mark. And `git status`'s trailing slash on an untracked directory path made `basename` return an empty string, which was silently dropped, so a brand-new uncommitted folder never appeared in the uncommitted-work summary.
Original changelogThe quota-restored notification is back, and a first reading past the threshold warns
**The quota-restored notification fires again, and a warning now fires on the very first reading if quota is already past the threshold.** Any reading below 100% cleared the "was depleted" flag, regardless of how small the drop was, so by the time a genuine reset was detected — a single-step drop large enough to distinguish a real period rollover from noise — the flag had already been cleared by an earlier, tiny fluctuation, and the restored notification never fired. It's now cleared only by that same reset detection. Separately, a warning threshold could only be crossed relative to a previous reading, so quota that was already above the threshold on the very first observation — for instance, the app launching mid-session at 95% — produced no warning at all, unlike the depleted notification, which always fires on a first reading of 100%.
Original changelogOne registry read failure no longer takes down the tray menu
**A dead plist key was removed, and a Windows registry read failure can no longer take down the whole tray menu.** `StartDelay` isn't a key `launchd.plist(5)` recognizes, so it had no effect and was doing nothing since it was added; it's gone. Separately, the Windows login-item's `is_enabled()` caught only `FileNotFoundError`, but it's called once per menu render to draw the "launch at login" checkbox — a registry permission error there propagated out and could break the whole tray menu instead of just that one row; it now catches any `OSError` and reports "not enabled."
Original changelogTerse mode's plain-language rule now covers text pasted from a subagent
**Terse mode's plain-language rule now also covers text pasted verbatim from a subagent.** The mode's own prompt only governed sentences Claude wrote itself, leaving no instruction for handling a subagent's report or a tool's raw output when quoted directly — jargon inside it went unexplained. A line was added after the existing "explain a term on first use" rule, in all five languages.