KaimonGate: raw-mode TUI apps wedge the host REPL on exit #67

Closed
opened 2026-07-19 08:25:36 -07:00 by disberd · 2 comments
disberd commented 2026-07-19 08:25:36 -07:00 (Migrated from github.com)

Hello, and first of all thank you very much for all the work you're doing on
Kaimon, Tachikoma (and KaimonSlate, which I tried a bit a few days ago) — it's
incredible how many valuable contributions you're stacking up in such a short
time!

I've been building an interactive variable explorer with Tachikoma: a TUI app
meant to be called on demand from a working REPL, rather than run as a
standalone app (like Kaimon). I also have Kaimon basically auto-loaded in all my
REPL sessions for easier agent integration, and ran into the odd issue described
below.

I think this is a genuine bug that deserves a fix, though I'm not sure the fix
location I suggest below is the appropriate one. All of the analysis and
debugging below was done together with AI.

Summary

When an interactive raw-mode TUI (any Tachikoma app(...)) is run from a
KaimonGate-hosted REPL, the REPL becomes unusable after the TUI exits:
typed input is echoed but never evaluated — no new prompt, no results. The
Julia process is alive (the Kaimon gate still evaluates over its socket), but
the REPL frontend can no longer read stdin, so the only recovery is killing and
restarting the REPL.

The same TUI run from a plain julia REPL (no Kaimon) exits cleanly. And in a
Kaimon session it also exits cleanly until the first gate eval — the wedge
starts only once the stdout-capture mux is installed, which is the tell-tale
fingerprint of the cause below.

MWE (two registered packages, no MCP server / no VS Code)

The wedge needs neither a running Kaimon server nor the MCP transport — the
single call KaimonGate._ensure_capture_installed!() (what the first gate eval
does under the hood) is the entire trigger. So it reproduces in a plain REPL
with only Tachikoma + KaimonGate installed:

# Setup once — both packages are registered in General:
#   import Pkg; Pkg.add(["Tachikoma", "KaimonGate"])
# Then start a plain REPL:  julia --startup-file=no

using Tachikoma, KaimonGate

# Install the stdout/stderr capture mux by hand — exactly what the first Kaimon
# gate eval does. After this, `stdout isa Base.TTY` is false.
KaimonGate._ensure_capture_installed!()

# simple_tree_demo.jl defines its update!/view/should_quit methods with bare
# names; import those from Tachikoma before including it at top level, otherwise
# `view` resolves to Base.view and the app errors before it starts (a
# Tachikoma-usage detail — unrelated to this bug, just makes the demo includable
# outside the TachikomaDemos module).
import Tachikoma: update!, view, should_quit
include(joinpath(pkgdir(Tachikoma), "demos", "TachikomaDemos", "src", "simple_tree_demo.jl"))

simple_tree_demo()     # arrow keys to move, press q to quit

1 + 1                  # ← typed but never evaluated: the REPL is wedged

Without the _ensure_capture_installed!() line the same demo quits cleanly —
that one call is the whole difference.

Confirmed recovery — restoring the real streams around the app makes it behave
exactly like a plain REPL:

KaimonGate._restore_capture!()
try
    simple_tree_demo()
finally
    KaimonGate._ensure_capture_installed!()
end
1 + 1                  # => 2, REPL healthy

(Verified on Julia 1.12.6, Tachikoma 2.3.6, KaimonGate 1.0.1.)

Platform note (Linux vs Windows)

The wedge occurs on TUI exit on both Linux and Windows, but recovery differs:

  • Linux: the REPL stays wedged. Ctrl+C does not un-wedge it — the only
    recovery is killing and restarting the process.
  • Windows: sending Ctrl+C after the TUI exits un-wedges the REPL and
    returns a usable prompt.

This asymmetry is consistent with the platform split in the stdin/console-input
handling (libuv wait(stdin) on Unix vs the polling console-input backend on
Windows): on Windows the interrupt appears to kick the input loop back into
reading; on Unix it does not. Either way the prevention (restore the real
streams around the app) fixes both platforms.

Root cause

KaimonGate._ensure_capture_installed! (lazily, on the first gate eval) rebinds
the process output streams to non-TTY wrappers for REPL mirroring:

setglobal!(Base, :stdout, _CaptureIO(:stdout, out0))
setglobal!(Base, :stderr, _CaptureIO(:stderr, err0))

stdin stays the real TTY; the accompanying comment notes the interactive REPL
uses its own terminal handle for line-editing, so mirroring is transparent to
the line editor
. It is not transparent to a raw-mode TUI framework.

Tachikoma's with_terminal / _start_capture gates its own terminal
save/restore on the stream being a literal Base.TTY:

if on_stdout !== nothing || stdout isa Base.TTY   # false once _CaptureIO is installed
    orig_stdout = stdout
    rd, wr = redirect_stdout()   # capture + restore around the app loop
    ...

With _CaptureIO installed, stdout isa Base.TTY is false, so Tachikoma
skips the capture/restore cycle it performs in a plain REPL. Running its
raw-mode enter/leave without that cycle leaves the host REPL's stdin reading
dead on exit. (Tachikoma renders to /dev/tty directly, so the TUI still
displays correctly — the damage is only visible after quitting.)

Proposed fix (KaimonGate side)

The mux only needs to be suspended while a TUI owns the terminal. KaimonGate
already integrates with Tachikoma via the _TACHIKOMA[] host hook and already
restores stream/terminal state for the restart path (prepare_for_exec!); the
missing piece is the symmetric handling for interactive app runs. Options,
cheapest first:

  1. Suspend the mux around Tachikoma.app / with_terminal. When
    set_tachikoma!(T) is called, wrap (or have Tachikoma call a host hook
    around) with_terminal so it runs _restore_capture!() on enter and
    _ensure_capture_installed!() on leave. Fixes every Tachikoma app with no
    per-app code — this is the real fix.

  2. Make the capture gate visible to frameworks (Tachikoma side, complementary).
    Have Tachikoma test "terminal-backed" rather than stdout isa Base.TTY
    (follow a wrapper down to its underlying tty). Helps any stdout wrapper, not
    just Kaimon's; worth an upstream Tachikoma issue regardless.

  3. Expose a public helperKaimonGate.with_real_streams(f) / with_tui(f)
    — so app authors can opt in explicitly. Lowest effort for KaimonGate, but
    pushes the burden onto every TUI author, and requires them to reach for the
    currently-private _restore_capture!/_ensure_capture_installed!.

Recommendation: (1) as the transparent fix, optionally with (2) filed upstream
so the gate is robust regardless of who wraps stdout. A public helper (3) is a
useful addition either way, since it lets app authors that must self-wrap avoid
the private-name coupling.

Hello, and first of all thank you very much for all the work you're doing on Kaimon, Tachikoma (and KaimonSlate, which I tried a bit a few days ago) — it's incredible how many valuable contributions you're stacking up in such a short time! I've been building an interactive variable explorer with Tachikoma: a TUI app meant to be called on demand from a working REPL, rather than run as a standalone app (like Kaimon). I also have Kaimon basically auto-loaded in all my REPL sessions for easier agent integration, and ran into the odd issue described below. I think this is a genuine bug that deserves a fix, though I'm not sure the fix location I suggest below is the appropriate one. All of the analysis and debugging below was done together with AI. ## Summary When an interactive raw-mode TUI (any Tachikoma `app(...)`) is run from a KaimonGate-hosted REPL, the REPL becomes unusable **after the TUI exits**: typed input is echoed but never evaluated — no new prompt, no results. The Julia process is alive (the Kaimon gate still evaluates over its socket), but the REPL frontend can no longer read stdin, so the only recovery is killing and restarting the REPL. The same TUI run from a plain `julia` REPL (no Kaimon) exits cleanly. And in a Kaimon session it also exits cleanly **until the first gate eval** — the wedge starts only once the stdout-capture mux is installed, which is the tell-tale fingerprint of the cause below. ## MWE (two registered packages, no MCP server / no VS Code) The wedge needs neither a running Kaimon server nor the MCP transport — the single call `KaimonGate._ensure_capture_installed!()` (what the first gate eval does under the hood) is the entire trigger. So it reproduces in a plain REPL with only Tachikoma + KaimonGate installed: ```julia # Setup once — both packages are registered in General: # import Pkg; Pkg.add(["Tachikoma", "KaimonGate"]) # Then start a plain REPL: julia --startup-file=no using Tachikoma, KaimonGate # Install the stdout/stderr capture mux by hand — exactly what the first Kaimon # gate eval does. After this, `stdout isa Base.TTY` is false. KaimonGate._ensure_capture_installed!() # simple_tree_demo.jl defines its update!/view/should_quit methods with bare # names; import those from Tachikoma before including it at top level, otherwise # `view` resolves to Base.view and the app errors before it starts (a # Tachikoma-usage detail — unrelated to this bug, just makes the demo includable # outside the TachikomaDemos module). import Tachikoma: update!, view, should_quit include(joinpath(pkgdir(Tachikoma), "demos", "TachikomaDemos", "src", "simple_tree_demo.jl")) simple_tree_demo() # arrow keys to move, press q to quit 1 + 1 # ← typed but never evaluated: the REPL is wedged ``` Without the `_ensure_capture_installed!()` line the same demo quits cleanly — that one call is the whole difference. Confirmed recovery — restoring the real streams around the app makes it behave exactly like a plain REPL: ```julia KaimonGate._restore_capture!() try simple_tree_demo() finally KaimonGate._ensure_capture_installed!() end 1 + 1 # => 2, REPL healthy ``` (Verified on Julia 1.12.6, Tachikoma 2.3.6, KaimonGate 1.0.1.) ## Platform note (Linux vs Windows) The wedge occurs on TUI exit on both Linux and Windows, but recovery differs: - **Linux**: the REPL stays wedged. `Ctrl+C` does **not** un-wedge it — the only recovery is killing and restarting the process. - **Windows**: sending `Ctrl+C` after the TUI exits un-wedges the REPL and returns a usable prompt. This asymmetry is consistent with the platform split in the stdin/console-input handling (libuv `wait(stdin)` on Unix vs the polling console-input backend on Windows): on Windows the interrupt appears to kick the input loop back into reading; on Unix it does not. Either way the prevention (restore the real streams around the app) fixes both platforms. ## Root cause `KaimonGate._ensure_capture_installed!` (lazily, on the first gate eval) rebinds the process output streams to non-TTY wrappers for REPL mirroring: ```julia setglobal!(Base, :stdout, _CaptureIO(:stdout, out0)) setglobal!(Base, :stderr, _CaptureIO(:stderr, err0)) ``` `stdin` stays the real TTY; the accompanying comment notes the interactive REPL uses its own terminal handle for line-editing, so mirroring is transparent *to the line editor*. It is **not** transparent to a raw-mode TUI framework. Tachikoma's `with_terminal` / `_start_capture` gates its own terminal save/restore on the stream being a literal `Base.TTY`: ```julia if on_stdout !== nothing || stdout isa Base.TTY # false once _CaptureIO is installed orig_stdout = stdout rd, wr = redirect_stdout() # capture + restore around the app loop ... ``` With `_CaptureIO` installed, `stdout isa Base.TTY` is `false`, so Tachikoma **skips** the capture/restore cycle it performs in a plain REPL. Running its raw-mode enter/leave without that cycle leaves the host REPL's stdin reading dead on exit. (Tachikoma renders to `/dev/tty` directly, so the TUI still *displays* correctly — the damage is only visible after quitting.) ## Proposed fix (KaimonGate side) The mux only needs to be suspended while a TUI owns the terminal. KaimonGate already integrates with Tachikoma via the `_TACHIKOMA[]` host hook and already restores stream/terminal state for the restart path (`prepare_for_exec!`); the missing piece is the symmetric handling for *interactive* app runs. Options, cheapest first: 1. **Suspend the mux around `Tachikoma.app` / `with_terminal`.** When `set_tachikoma!(T)` is called, wrap (or have Tachikoma call a host hook around) `with_terminal` so it runs `_restore_capture!()` on enter and `_ensure_capture_installed!()` on leave. Fixes *every* Tachikoma app with no per-app code — this is the real fix. 2. **Make the capture gate visible to frameworks (Tachikoma side, complementary).** Have Tachikoma test "terminal-backed" rather than `stdout isa Base.TTY` (follow a wrapper down to its underlying tty). Helps any stdout wrapper, not just Kaimon's; worth an upstream Tachikoma issue regardless. 3. **Expose a public helper** — `KaimonGate.with_real_streams(f)` / `with_tui(f)` — so app authors can opt in explicitly. Lowest effort for KaimonGate, but pushes the burden onto every TUI author, and requires them to reach for the currently-private `_restore_capture!`/`_ensure_capture_installed!`. Recommendation: (1) as the transparent fix, optionally with (2) filed upstream so the gate is robust regardless of who wraps stdout. A public helper (3) is a useful addition either way, since it lets app authors that must self-wrap avoid the private-name coupling.
kahliburke commented 2026-07-19 21:15:52 -07:00 (Migrated from github.com)

@disberd Thanks for the report and kind words, I will take a look soon.

@disberd Thanks for the report and kind words, I will take a look soon.
kahliburke commented 2026-07-22 19:14:13 -07:00 (Migrated from github.com)

Fixed. Root cause: KaimonGate's output-capture mux rebinds Base.stdout/stderr
to non-TTY wrappers, so Tachikoma's with_terminal saw stdout isa Base.TTY as
false and skipped its terminal capture/restore cycle — the raw-mode enter/leave
then left the host REPL's stdin read wedged on exit.

The fix is a stream-guard handshake between the two packages: Tachikoma 2.4 adds a
set_stream_guard! host hook, and KaimonGate now registers its
_with_uncaptured_streams suspender as that guard, so a TUI launched from a gate
REPL runs with the real process streams restored and exits cleanly. It's a no-op
for standalone Tachikoma and self-heals on the first eval, so no user action is
needed beyond upgrading. A disable_wedge_guard! opt-out exists for a host that
runs its own persistent full-screen TUI alongside an active mux.

Ships in KaimonGate 1.1.0 (merged to General) and Kaimon 2.2.0 (registering
now), which requires Tachikoma 2.4. Once your registries update, ]up will pull
them in.

Thanks for the clear report and MWE, @disberd — it made this one much easier to
pin down.

Fixed. Root cause: KaimonGate's output-capture mux rebinds `Base.stdout`/`stderr` to non-TTY wrappers, so Tachikoma's `with_terminal` saw `stdout isa Base.TTY` as false and skipped its terminal capture/restore cycle — the raw-mode enter/leave then left the host REPL's stdin read wedged on exit. The fix is a stream-guard handshake between the two packages: Tachikoma 2.4 adds a `set_stream_guard!` host hook, and KaimonGate now registers its `_with_uncaptured_streams` suspender as that guard, so a TUI launched from a gate REPL runs with the real process streams restored and exits cleanly. It's a no-op for standalone Tachikoma and self-heals on the first eval, so no user action is needed beyond upgrading. A `disable_wedge_guard!` opt-out exists for a host that runs its own persistent full-screen TUI alongside an active mux. Ships in **KaimonGate 1.1.0** (merged to General) and **Kaimon 2.2.0** (registering now), which requires Tachikoma 2.4. Once your registries update, `]up` will pull them in. Thanks for the clear report and MWE, @disberd — it made this one much easier to pin down.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kahliburke/Kaimon.jl#67
No description provided.