Skip to content

feat(mirror): capture engine and tool output via a pty - #92

Merged
ralyodio merged 1 commit into
mainfrom
feat/mirror-pty-capture
Jul 30, 2026
Merged

feat(mirror): capture engine and tool output via a pty#92
ralyodio merged 1 commit into
mainfrom
feat/mirror-pty-capture

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The session page showed the pit's own output and nothing else — "it only prints sometimes."

Why

teeOutput() patches process.stdout/stderr, but children are spawned like this:

// src/engines.mjs
child = spawn(spec.cmd, spec.args, { stdio: "inherit", env });

inherit hands the child the tty's own file descriptors, so its bytes never pass
through this process. Result: you get the banner, prompts, /help, and hand-off
notices — and nothing from claude, opencode, gh, or ugig. Not intermittent;
exactly and always. mirror.mjs documented it as a known limitation.

The transport was never the problem: apps/pwa already streams over SSE with
reconnect.

Why script(1)

  • A pipe won't do. Every one of these programs checks isTTY and drops colour,
    prompts, and full-screen UI when it sees one.
  • node-pty won't do. Native module; moshcode installs by untarring a release and
    running node (install.sh) — no compiler in that path.
  • script(1) allocates the same real pseudo-terminal using only the base system.

Verified end to end

Ran a probe child through the new path:

HELLO-FROM-CHILD
isTTY=true          ← child still sees a real terminal
ok: true code: 23   ← exit status still propagates
captured: "HELLO-FROM-CHILD\r\nisTTY=true\r\n"

Blast radius

Capture engages only when a mirror is attached. No mirror → the untouched
inherit path, byte for byte what ships today. No usable script(1), or
MOSHCODE_MIRROR_PTY=0 → same fallback. util-linux and BSD flag sets are both
handled and pinned by tests; anything unrecognised falls back rather than guessing.

script -q silences its "Script started/done" notices on the terminal but still
writes them to the transcript, so those are stripped — anchored to start/end so
child output containing those words survives.

Tests

12 new in test/pty.test.mjs (both flavours' argv, shQuote injection safety,
banner stripping, follower streaming + tail drain). Full suite: 325 tests, 247
pass, 0 fail
, 78 pre-existing skips.

Not in this PR

The page renders SGR colour only — sessions.mjs:352 drops every non-SGR escape. So
a full-screen TUI now arrives but won't render. Making the session page a real
terminal (arrow keys, history, cursor addressing) needs xterm.js plus raw keystroke
relay into the pty. That's the follow-up.

🤖 Generated with Claude Code

The session page showed the pit's own output and nothing else. teeOutput()
patches process.stdout/stderr, but engines and tools are spawned with
`stdio: "inherit"`, so the child writes to the tty's file descriptors directly
and not one of its bytes passes through this process. Everything you actually
went to the terminal to do — claude, opencode, gh, ugig — was invisible.

A pipe is not the fix: every one of these programs checks isTTY and drops
colour, prompts, and full-screen UI the moment it is talking to one. node-pty
is not the fix either — it is a native module, and moshcode installs by
untarring a release and running node.

So run the child under script(1), which allocates a real pseudo-terminal using
nothing but the base system, and follow its transcript. Verified end to end: the
child still reports isTTY=true, its exit code still propagates (23 in and 23
out), and its output arrives in the sink.

Capture only engages when a mirror is actually attached, so an unmirrored
session keeps the exact inherit path it has today and the blast radius stays
inside mirrored sessions. No usable script(1), or MOSHCODE_MIRROR_PTY=0, falls
back the same way.

`script -q` silences its "Script started/done" notices on the terminal but still
writes them into the transcript, so those are stripped — anchored to the start
and end so output that merely contains the words survives.

Not covered: the session page renders SGR colour only (sessions.mjs drops every
other escape), so a full-screen TUI will arrive but not render correctly. That
needs xterm.js on the page, plus keystroke relay for arrow keys and history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit c7cde48 into main Jul 30, 2026
3 checks passed
@ralyodio
ralyodio deleted the feat/mirror-pty-capture branch August 8, 2026 17:05
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.

1 participant