fix(cli): keep CLI alive when stdout is redirected on Windows#4
Open
GuyMannDude wants to merge 2 commits into
Open
fix(cli): keep CLI alive when stdout is redirected on Windows#4GuyMannDude wants to merge 2 commits into
GuyMannDude wants to merge 2 commits into
Conversation
The module-level `console = Console()` blew up on Windows under any non-interactive context (Scheduled Task, SSH-stdio, `*>>` redirection) because rich's writer routes through `_win32_console.LegacyWindowsTerm` and its fallback path writes through Python's cp1252 stdout encoder, which can't encode the ⚡ in the banner. The watcher exited before it ever ran, silently breaking auto-capture on Windows. Surfaced today wiring Dave Crash Test Moltman (the IGOR-2 OpenClaw test bed) to Mnemo — `mnemo-cortex watch --backfill -f *>> log` died on the banner before the watch loop started. Full repro + stack trace in issue #3. Fix: wrap the Console construction in a helper that respects whether stdout is a TTY. - TTY path → `Console()` with defaults, so colors / boxes / progress bars work exactly as before in interactive shells on any platform. - Non-TTY path → wrap `sys.stdout.buffer` in a UTF-8 `TextIOWrapper` with `errors='replace'` and hand it to `Console(file=..., force_terminal=False)`. Two belts: 1. `force_terminal=False` keeps rich out of the win32 console write path entirely. 2. UTF-8 wrapper with replacement errors means even if some future banner addition contains a character UTF-8 can't represent (impossible, but defensive), we degrade rather than crash. - Defensive fallback for exotic `sys.stdout` replacements that have no `.buffer` (custom stream classes etc.) → `Console(force_terminal=False)` on the as-is file. Worst case: plain text, no crash. Verified locally: - Non-TTY path: replaced `sys.stdout` with a `BytesIO`-backed stub reporting `isatty()==False`, imported `console` + `BANNER`, called `console.print(BANNER)` — banner rendered cleanly with `⚡` preserved in the output, no `UnicodeEncodeError`. - Subprocess-isatty=False construction: `console.print('[bold green]ok[/]')` succeeds (with style stripped since `force_terminal=False`). - `py_compile agentb/cli.py` clean. Windows verification deferred to whoever lands this — I built the workaround for Dave first (a stripped-down launcher that imports `agentb.watcher.run_watcher` / `backfill_sessions` directly), confirmed it captured 3 exchanges to artforge:50001, then designed this fix as the upstream replacement. The local launcher becomes unnecessary once this lands. Closes #3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The nightly wiki compiler returned early without saving state when harvest matched zero memories. Single zero-result night wedged the cursor forever: subsequent nights queried the same stale `since`, found the same nothing, returned early before save_state(), cursor never moved. Observed wedge May 17 → May 24 (8 nights of "Harvested 0 memories" with the window stuck at 2026-05-17T10:30:02 in production on artforge). - Empty-harvest path now writes `state["last_run"] = run_started_at` before returning. The window advances; tomorrow night queries fresh memories from today instead of re-querying the same dead window. - Bonus: capture `run_started_at` BEFORE harvest rather than after the compile loop. Memories arriving during a long compile (several minutes for many topics) were previously missed on the next run because state["last_run"] got set to post-compile-time. Now any memory that lands during the compile is picked up next night. Cursor semantics: `last_run` = "the moment we last attempted a compile," not "the moment we last finished one." That's correct because the harvest window is `since=last_run`, and we want every memory whose mtime > last-attempt to be considered next time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
mnemo-cortex watch(and any other CLI command that prints the BANNER) blew up withUnicodeEncodeError: 'charmap' codec can't encode character '⚡'on Windows whenever stdout was redirected — Scheduled Task, SSH-stdio,*>>log redirection, anywhere stdin/stdout aren't an interactive console._win32_console.LegacyWindowsTerm, whose fallback path writes through Python's stdout encoder — which defaults tocp1252on Windows. The ⚡ in the BANNER can't be encoded bycp1252, the print raises, the watcher dies before its loop ever starts. Auto-capture is silently broken under any Windows non-interactive context.Console()construction in a helper that branches onsys.stdout.isatty():Console()with defaults — colors / boxes / progress bars work exactly as before.sys.stdout.bufferin a UTF-8TextIOWrapper(witherrors='replace'for genuinely un-encodable future characters), hand it toConsole(file=…, force_terminal=False).force_terminal=Falsekeeps rich out of the win32 console code path entirely; the UTF-8 wrapper handles any encoding-time defense..buffer(custom stream classes): fall through toConsole(force_terminal=False)on the as-is file — worst case is plain text, never a crash.Why this approach over the alternatives
Three other fixes were on the table:
UnicodeEncodeErroraround every banner print. Treats the symptom, not the cause. Other rich-styled prints elsewhere would still crash.Console(force_terminal=False)only, no UTF-8 wrap. Half the fix.force_terminal=Falsekeeps rich out of the win32 console path, but the underlyingsys.stdout.write()still goes throughcp1252on Windows, so the ⚡ still crashes.The TTY-detect + UTF-8-wrap approach in this PR is the smallest change that fully fixes the bug at construction time, costs nothing in interactive terminals (they take the original
Console()path), and degrades gracefully on the non-interactive path.Test plan
Local (the platform I'm on — Linux):
sys.stdoutwith aBytesIO-backed stub reportingisatty()==False, importedconsole+BANNER, calledconsole.print(BANNER). Banner rendered cleanly with⚡preserved in output; noUnicodeEncodeError.isatty=Falseconstruction:console.print('[bold green]ok[/]')succeeds (style stripped becauseforce_terminal=False).python -m py_compile agentb/cli.pyclean.Windows verification — needs whoever lands this to confirm:
mnemo-cortex watch --backfill -f *>> watch.logfrom PowerShell — log shows the banner + "Watching: …" + "Mnemo: ✓ connected" without a Python traceback.mnemo-cortex(no args) in a normal PowerShell window — banner still renders in bold yellow.Background: I built a stripped-down launcher (imports
agentb.watcher.run_watcher+backfill_sessionsdirectly, skips the click group entirely) as the workaround for a deployment that needed auto-capture immediately. The launcher captured 3 exchanges to artforge:50001 cleanly, which proves the watcher body itself is fine on Windows — it's only the CLI's banner that's broken. Once this PR lands, that workaround becomes unnecessary.Closes #3.
🤖 Generated with Claude Code