Repository navigation
fix(tui): only the renderer may write to the terminal while the pane is up - #408
Merged
Merged
Conversation
…is up
stdio MCP servers were spawned with stderr: "inherit", so their own logging
painted straight over the UI — mcp-remote (the bridge for Linear, Notion,
Sentry) logs every call as `[pid] [Local→Remote] tools/call`. It is the third
leak of this kind (jsdom CSS warnings, library console.error), each fixed
where it showed up. This closes the class:
- MCP: server stderr is piped and drained (StderrTail) — every line to the
debug trace (TSFORGE_TRACE); the last lines explain a server that dies
("connection closed unexpectedly: fatal: …"), including stderr that lands
just after stdout closes.
- Runtime: TerminalGuard engages exactly while the pane holds the screen.
The renderer writes through a handle bound to the real stdout; any other
process.stdout.write becomes transcript text, process.stderr.write and
console.* (Bun's console bypasses the streams) go to the trace. Fixes the
harness's own raw writes under the pane too (`/gate`, `/plan`, /model's
notices). Deliberate raw writers (editor control codes, scaffold wizard,
recipe picker) use the owned handle explicitly.
- Static (CI): tests/terminal-ownership.test.ts parses src/ and fails on any
Bun.spawn/spawnSync that inherits or omits stderr (Bun's default is
"inherit") and any child_process stdio: "inherit".
- End to end: scripts/e2e-terminal-ownership-pty.py drives the real UI with
a noisy stdio MCP server called mid-session and fails if one byte of its
stderr reaches the terminal, or if /gate's line is painted raw.
Each guard was checked against the bug it prevents: reverting to
stderr: "inherit" fails the static check (naming the line) and the PTY test;
disabling the guard fails the PTY test; unit tests caught the guard restoring
bound copies instead of the original writers.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
tsforge | 87c7474 | Commit Preview URL Branch Preview URL |
Sep 29 2026, 01:00 PM |
This was referenced Sep 29, 2026
Closed
Merged
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.
The bug
Calling any stdio MCP tool painted the server's own logging over the UI.
mcp-remote(the bridge Linear, Notion and Sentry use) logs every message as[pid] [Local→Remote] tools/call, andStdioMcpTransportspawned servers withstderr: "inherit", so those bytes went straight to the terminal. It affects every stdio MCP server; HTTP servers (Twenty) have no child process.This is the third leak of this class: jsdom's CSS warnings, library
console.error, and now MCP stderr. Each earlier fix patched the site that showed it. This PR closes the class.The fix, in three layers
1. MCP stderr is captured, not inherited.
StderrTaildrains each server's stderr. Every line goes to the debug trace (TSFORGE_TRACE), and the last lines explain a server that dies:connection closed unexpectedly: fatal: …. That includes stderr arriving just after stdout closes, which is common for a dying bridge.2. Runtime:
TerminalGuard. It engages exactly while the pane holds the screen (a newPaneScreenactivity hook) and releases on leave or exit.process.stdout.writebecomes transcript text.process.stderr.writeandconsole.*go to the trace. Bun'sconsolewrites natively and bypasses the stream methods, so it's taken over separately./gate,/plan,/modelnotices were painting over the frame).3. CI: a static check plus an end-to-end check.
tests/terminal-ownership.test.tsparsessrc/with the TS compiler. It fails on anyBun.spawn/spawnSyncthat inherits stdout/stderr or omits stderr (Bun's default is"inherit"), and on anychild_processstdio: "inherit". It names the line and the fix.scripts/e2e-terminal-ownership-pty.py(added toe2e:pty) drives the real UI in a PTY. A noisy stdio MCP server is called by the model mid-session, and the test fails if one byte of its stderr reaches the terminal, or if/gate's line is painted raw.The rule is written into
AGENTS.mdhouse rules.Proof the guards work
Each guard was run against the bug it prevents:
stderr: "inherit"in the MCP transportmcp/stdio-transport.ts:53 … pipe it) and PTY test (found: ['Local→Remote', '[4242]'])/gate's confirmation was not written raw over the frame)mcp-stderr.test.ts(shell server that closes stdout first)terminal-guard.test.ts(it caught this during development)Against the real Linear
mcp-remotebridge: 0 bytes reached the terminal, and all 29 log lines went to the trace.bun run ci:local: 6329 pass, 0 fail. Every PTY suite passes, including the wizard, config, editor and scaffold flows that use raw writes.