Skip to content

fix(cli): keep CLI alive when stdout is redirected on Windows#4

Open
GuyMannDude wants to merge 2 commits into
masterfrom
fix/cli-windows-stdout-redirect-cp1252
Open

fix(cli): keep CLI alive when stdout is redirected on Windows#4
GuyMannDude wants to merge 2 commits into
masterfrom
fix/cli-windows-stdout-redirect-cp1252

Conversation

@GuyMannDude

Copy link
Copy Markdown
Owner

Summary

  • Fixes the silent-crash described in watcher CLI crashes on Windows when stdout is redirected — rich banner emoji + cp1252 #3: mnemo-cortex watch (and any other CLI command that prints the BANNER) blew up with UnicodeEncodeError: '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.
  • Root cause: rich's writer routes through _win32_console.LegacyWindowsTerm, whose fallback path writes through Python's stdout encoder — which defaults to cp1252 on Windows. The ⚡ in the BANNER can't be encoded by cp1252, the print raises, the watcher dies before its loop ever starts. Auto-capture is silently broken under any Windows non-interactive context.
  • Fix wraps the module-level Console() construction in a helper that branches on sys.stdout.isatty():
    • TTY path (any platform): Console() with defaults — colors / boxes / progress bars work exactly as before.
    • Non-TTY path: wrap sys.stdout.buffer in a UTF-8 TextIOWrapper (with errors='replace' for genuinely un-encodable future characters), hand it to Console(file=…, force_terminal=False). force_terminal=False keeps rich out of the win32 console code path entirely; the UTF-8 wrapper handles any encoding-time defense.
    • Exotic stdout with no .buffer (custom stream classes): fall through to Console(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:

  1. Drop the ⚡ from the BANNER. Simplest. Loses brand. Doesn't help if any future banner addition reintroduces non-ASCII (Panel borders, em-dashes, etc.).
  2. Catch UnicodeEncodeError around every banner print. Treats the symptom, not the cause. Other rich-styled prints elsewhere would still crash.
  3. Console(force_terminal=False) only, no UTF-8 wrap. Half the fix. force_terminal=False keeps rich out of the win32 console path, but the underlying sys.stdout.write() still goes through cp1252 on 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):

  • 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 output; no UnicodeEncodeError.
  • Subprocess-isatty=False construction: console.print('[bold green]ok[/]') succeeds (style stripped because force_terminal=False).
  • python -m py_compile agentb/cli.py clean.

Windows verification — needs whoever lands this to confirm:

  • mnemo-cortex watch --backfill -f *>> watch.log from PowerShell — log shows the banner + "Watching: …" + "Mnemo: ✓ connected" without a Python traceback.
  • Same command driven by a Windows Scheduled Task — same outcome.
  • Interactive 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_sessions directly, 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

Rocky Moltman and others added 2 commits May 23, 2026 20:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

watcher CLI crashes on Windows when stdout is redirected — rich banner emoji + cp1252

1 participant