fix(mirror): capture shell + launcher output to the session page - #451
Merged
Conversation
`/merge` on app.moshcode.sh/sessions/<id> showed the echoed command line and the exit note with nothing in between. Not a stderr problem — a shell command's whole output was invisible. `/merge` is a shell-valued alias (`gh-prs-merge-all --apply`), so it expands to `!cmd` and lands in runShell, which spawned with `stdio: "inherit"`: those bytes go to the tty's own file descriptors and never pass through this process, so neither the teeOutput hook nor the pty capture ever saw them. Only engines and workflow tools had been taught to run under script(1); every other launcher had not. So the capture moves into one place — captureSpec in src/pty.mjs — and defaults its sink to the live mirror rather than waiting to be handed one. runShell (`!cmd`, /shell, every shell alias), /install, and runCmd (the upgrader, /plugin, /skill, /mcp) all reach the session page now, stdout and stderr alike, and a launcher written tomorrow gets it without knowing the mirror exists. Nothing changes for an unmirrored pit or a box with no usable script(1): the spec comes back untouched. Second fix, same symptom from the other end: POST /output stored `chunk.slice(0, 20000)` while publishing the full chunk to watchers, so a burst bigger than the row cap was on screen live and gone after a reload. The cap stays; the overflow becomes more rows in seq order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH
ThreatCrush Security Scan1 finding(s) in the 10 file(s) this pull request changes. MEDIUM: 1
81 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 66 | **LOW**: 10Not introduced by this pull request. The full set is in the Security tab.
…and 61 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
Verified against the build actually installed (v0.74.0, ~/.moshcode/pkg) by pointing a real mirrored pit at a stand-in app and recording what it posts: a shell command's stdout AND stderr both reach the terminal and neither reaches the session page. Not stderr-specific, and not /merge -specific — every shell-valued alias went the same way. The async launchers were the first pass. These are the spawnSync ones, equally invisible and equally pit-reachable: moshscript's shell() (so `/run`), /billing handing an invoice to coinpay, and /payments connect. A blocking spawn holds the event loop, so the follower's poll never runs and the output arrives in the drain stop() does — batched rather than live, which is still the difference between reading it from a phone and not. Left uncaptured on purpose: escalate.mjs (a sudo password prompt is not something to route through a web page) and the tmux hand-offs, which hand over the terminal wholesale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH
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.
/mergeonapp.moshcode.sh/sessions/<id>showed the echoed command line and the exit note with nothing in between. It reads like stderr being dropped; it is not — a shell command's entire output was invisible to the mirror.Why
/mergeis a shell-valued alias (gh-prs-merge-all --apply), soexpandAliasturns it into!gh-prs-merge-all --applyand it lands inrunShell, which spawned withstdio: "inherit". Those bytes go straight to the tty's own file descriptors:teeOutput(which only sees what this process prints) never sees them, and the script(1) pty capture had only ever been wired intoopenPassthrough— engines and workflow tools. Every other launcher in the pit was writing to the terminal and nowhere else.What changed
captureSpecinsrc/pty.mjs(transcript, flavour-specificscriptargv, follower, banner strip, cleanup).openPassthroughnow calls it instead of carrying its own copy.captureSpecdefaults its sink to the live mirror (activeChildSink()insrc/mirror.mjs) rather than waiting to be handed one. A launcher gets capture without knowing the mirror exists — which is the failure mode this bug was: each launcher had to be taught separately, and only one ever was.runShell(!cmd,/shell, every shell-valued/alias— the/mergecase),/install, andrunCmd's inherited branch (/upgrade,/plugin,/skill,/mcp). stdout and stderr both, since they share the pty.script(1), orMOSHCODE_MIRROR_PTY=0: the spec comes back exactly as passed and the oldinheritpath runs.Second fix, same symptom from the other end
POST /api/sessions/:id/outputstoredchunk.slice(0, 20000)but published the full chunk to live watchers. A burst larger than the row cap was on screen while you watched and gone after a reload, with nothing to say bytes were missing. The row cap stays; the overflow becomes additional rows inseqorder, so a replay reassembles what was posted.Tests
test/mirror-shell-capture.test.mjs(new, 10 tests) — including the full chain:runShell→ pty → mirror sink → the/outputPOST body, asserting stderr survives it. The two that matter fail withMOSHCODE_MIRROR_PTY=0, so they are testing the wiring and not just the parts.apps/pwa/test/sessions-output-seq.test.mjs— the test that pinned truncation now pins splitting; every byte posted must be stored, in order.apps/pwasuite: 710 pass, 0 fail.🤖 Generated with Claude Code
https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH