terminal issue #43
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_router#43
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What needs to be done is:
Your PTY layer in
hero_routermust stop treating terminal protocol traffic as normal user-visible output.Right now it is acting like a byte pipe. That is not enough for interactive shells. A PTY bridge must understand three different streams mixed into one byte flow:
The leaked
ESC [ 2 ; 8 Rproves that a terminal query/reply roundtrip is being mishandled.What is happening
Some process inside the PTY sends:
That means: “terminal, report cursor position”.
A real terminal emulator receives that, does not display it, and sends back something like:
to the PTY master as input to the application.
Your current layer is almost certainly doing one of these wrong:
Correct model
hero_routerneeds a proper terminal session bridge with two logical directions:PTY -> client
This direction carries:
Client -> PTY
This direction carries:
ESC [ row ; col RThe crucial rule:
A terminal reply must never be appended to visible output. It must be injected into PTY input.
What to implement
1. Split terminal transport into semantic message types
Do not keep a single untyped byte stream at the router boundary.
Define explicit message kinds such as:
At minimum,
hero_routerneeds to distinguish:Without this separation, leaks like this will keep happening.
2. Add a VT/ANSI parser on the PTY->client path
When bytes come from the PTY master, parse them incrementally.
You need to recognize:
A parser crate is strongly recommended rather than handwritten parsing.
In Rust, look at:
vteweztermparser code as architectural referencealacritty_terminalas a reference implementation if you want terminal-state behaviorYou do not need a full terminal emulator inside
hero_router, but you do need enough parsing to:3. Detect terminal queries and mark them as requiring a reply
Common ones to support first:
Cursor position report request
Application sends:
Expected terminal reply:
Primary device attributes
Application sends:
or
Secondary DA
Application sends:
Terminal size is usually handled separately through resize events, not query/reply, but some apps may probe.
For MVP, the critical one is:
CSI 6 nbecause that is exactly what leaked.
4. Decide where terminal emulation lives
You need one clear architecture choice.
Option A — frontend is the terminal emulator
Best if your browser/native client already renders terminal content.
Then
hero_routershould:CSI 6 n, it computes the answerTerminalReplyThis is usually the cleanest design.
Option B — router partially emulates terminal behavior
Useful if the frontend is dumb or multiple frontends exist.
Then
hero_routermust:This is harder and usually unnecessary unless you want headless deterministic terminal sessions.
For Hero, I would recommend:
Frontend emulates, router routes.
But the router still needs parsing and typed channels.
5. Add a reply injection path back into PTY stdin
This is the missing behavior.
When the terminal side generates:
the router must not broadcast it as output.
It must do:
as if the user had typed it, but tagged internally as terminal-generated input, not human input.
That is the main functional fix.
6. Track terminal state needed for replies
For CPR support, you need current:
If frontend is authoritative, it returns these.
If router is authoritative, it must track them.
If you do not track cursor position, then queries like
CSI 6 ncannot be answered correctly.For MVP you can support:
But again, easier to let frontend answer CPR.
7. Ensure raw logs never include reply bytes as visible text
If you log PTY traffic, keep two log modes:
Debug raw mode
Hex/escaped bytes, explicitly labeled direction:
User-visible transcript mode
Only printable rendered text, no raw escape sequences
This is important because right now your system seems to be mixing protocol and transcript.
8. Preserve sequence boundaries with incremental parsing
Reads from PTY will be chunked arbitrarily.
You cannot assume one
read()contains one escape sequence.So the parser must be incremental and stateful across reads:
ESC [2;8RIf you do not do this, parsing will be flaky.
9. Add feature gating / compatibility policy
Define what terminal features Hero supports.
For MVP, explicitly support:
CSI 6 n)Unsupported sequences should:
10. Handle browser terminal integration properly
If your Hero frontend is browser-based and maybe using xterm.js or equivalent, then the clean flow is:
Do not let the frontend “print” reply bytes into the terminal DOM/output stream.
Required spec for
hero_routerHere is the concrete behavior spec.
Session model
Each terminal session must maintain:
Optional if router emulates more:
Transport API
Define separate operations:
terminal.openterminal.inputterminal.resizeterminal.replyterminal.closeExample conceptual OpenRPC surface:
terminal.inputUser keystrokes/paste:
terminal.resizeterminal.replyFor emulator-generated replies:
This separation is important. Do not overload user input and terminal replies invisibly.
Routing rules
PTY output received
terminal.replyTerminal reply received from frontend
Resize received
TIOCSWINSZSIGWINCHMVP implementation plan
Phase 1 — fix the bug
Implement just enough to stop leaks.
terminal.replyThis alone should solve the
^[ [2;8Rartifact.Phase 2 — parser and observability
Phase 3 — compatibility
Tests you need
At minimum:
1. CPR roundtrip
App writes:
Frontend sends reply:
Assert:
2. Chunked sequence handling
PTY output chunks:
ESC[6nAssert query still recognized.
3. Mixed printable + query
Output:
Assert:
helloandworld4. Resize
Resize event updates PTY size correctly.
5. Unsupported sequence
Unknown CSI/OSC does not crash parser and does not leak malformed state.
Recommended design choice
For Hero, I would define it like this:
That gives you:
Crisp implementation requirement
In one sentence:
Convert the current PTY byte tunnel into a terminal-aware session bridge that distinguishes visible output from terminal control/reply traffic, and inject reply sequences back into PTY stdin instead of rendering them.
I can turn this into a copy-paste engineering spec in markdown with sections for architecture, RPC methods, Rust structs, and acceptance criteria.
the tty is on hero_proc
we have UI there as well needs to be fixed as well
for here its UI on hero_router_ui
Hero Router Terminal Issue #43 — Implementation Spec (Phase 1 MVP)
Objective
Stop the hero_router terminal UI from leaking terminal-reply control bytes (e.g.
ESC [ 2 ; 8 Rfrom a CPR roundtrip) into the visible shell transcript, and promote terminal replies to a first-class, typed message on the PTY transport. Phase 1 only: maketerminal.replya typed OpenRPC method, split the browser↔router WebSocket traffic into semantic frame types (PtyOutput,UserInput,TerminalReply,Resize), and make the xterm.js integration tag reply bytes explicitly so they cannot land in the user transcript. No full VT/ANSI parser is introduced inside the router — the frontend xterm.js remains the authoritative terminal emulator.Context & scope boundary
The
hero_routerrepo does not own the PTY. PTYs are spawned inside hero_proc as jobs (viaActionBuilder::new(...).tty().is_process()). Router simply:terminal.list/create/get/delete), implemented as hero_proc jobs namedrouter_term_<name>.GET /api/terminal/pty/:namethat tunnels byte frames between the browser and hero_proc's/api/jobs/:id/ptyover the hero_proc Unix socket.The visible-text leak of
ESC [ 2 ; 8 Ris happening upstream in hero_proc's PTY read/write plumbing, not in hero_router. What hero_router can fix in Phase 1:TerminalEventframing on the browser↔router WebSocket so the UI never sends "reply bytes" and "keyboard input bytes" through the same unlabelled channel.terminal.replyOpenRPC method so external (non-browser) callers can inject a reply for a given session name.The actual "write reply to PTY master stdin instead of visible output" fix lives in hero_proc and is out of scope for this issue in this repo.
Requirements
TerminalEventenum serialized as tagged JSON text frames on the browser↔router WebSocket:PtyOutput,UserInput,TerminalReply,Resize. Binary frames continue to mean raw PTY output for backward compatibility during the transition.terminal.reply { name, data_b64 }to the router's OpenRPC (rpc.sock) surface with dispatch inserver/rpc.rsand schema instatic/openrpc.json.server/terminal.rs, split the WebSocket proxy (pty_proxy) to decode either typed JSON envelopes or legacy raw bytes.Resizeframe is received.static/js/terminal.jsso that:term.onDataand is sent asUserInput.TerminalReply.term.write().TERMINAL_LOG=raw|transcript|offenv flag (defaultoff) controlling atracing::debug!channel that prints each frame with its direction and type.Files to modify / create
crates/hero_router/src/server/terminal.rs— addTerminalEventenum, per-sessionTerminalStatemap, reworkpty_proxyinto two labelled pumps, addinject_reply(session_name, bytes)helper.crates/hero_router/src/server/rpc.rs— dispatchterminal.reply { name, data_b64 }.crates/hero_router/static/openrpc.json— add theterminal.replymethod definition.crates/hero_router/static/js/terminal.js— switch outbound frames to typed envelopes, add CPR/DA detection onterm.onData.crates/hero_router/templates/terminal.html— optional?termlog=rawdiagnostic script block.crates/hero_router/Cargo.toml— addbase64if not already a direct dep.vteis NOT added in Phase 1.Implementation Plan
Step 1 — Define the TerminalEvent wire format
Files:
crates/hero_router/src/server/terminal.rsTerminalEventenum (PtyOutput, UserInput, TerminalReply, Resize) with#[serde(tag = "kind", rename_all = "snake_case")].TerminalState { cols, rows }and anArc<Mutex<HashMap<String, TerminalState>>>-style map wired intoRpcState.Dependencies: none.
Step 2 — Rework the PTY WebSocket proxy into typed pumps
Files:
crates/hero_router/src/server/terminal.rs(pty_proxy)Message::TextattemptTerminalEventdecode; on success handleUserInput/TerminalReply/Resizeexplicitly.{"resize":{…}}text to hero_proc (current wire shape expected by hero_proc_server).Message::Binary, forward as legacy input; warn once per connection.term.writethem).DashMap<String /* name */, mpsc::Sender<Vec<u8>>>at attach time, unregister on close, for use byinject_reply.Dependencies: Step 1.
Step 3 — Add the
terminal.replyOpenRPC methodFiles:
crates/hero_router/src/server/rpc.rsnameanddata_b64, base64-decode, callterminal::inject_reply(name, &bytes).inject_reply(name, bytes)interminal.rslooks up the registered sink and pushes bytes to hero_proc as Binary; returns a clean error if no session is attached.Dependencies: Step 1, Step 2.
Step 4 — Document
terminal.replyin the OpenRPC specFiles:
crates/hero_router/static/openrpc.jsonnameanddata_b64and a{ok: boolean}result.Dependencies: Step 3.
Step 5 — Frontend: tag outbound frames, isolate reply path
Files:
crates/hero_router/static/js/terminal.jssendEvent(evt)→ JSON +ws.send(str).term.onDatahandler with one that:^\x1b\[\d+;\d+R), DA (^\x1b\[\?[\d;]*c), Secondary DA (^\x1b\[>[\d;]*c).{kind:"terminal_reply", data_b64: btoa(bytes)}.{kind:"user_input", data_b64: btoa(bytes)}.{kind:"resize", cols, rows}.?termlog=rawconsole.debug of inbound chunks.term.writewith bytes received fromterm.onData.Dependencies: Step 2.
Step 6 — Backward compatibility on the WebSocket
Files:
crates/hero_router/src/server/terminal.rs{"resize":…}+ raw Binary.Dependencies: Steps 1–2.
Step 7 — Minimal terminal state tracking + logs
Files:
crates/hero_router/src/server/terminal.rsTerminalStateon Resize.TERMINAL_LOGenv var;rawincludes replies and inputs;transcriptexcludes replies/resize; defaultoff.Dependencies: Steps 1–2.
Step 8 — Note the upstream gap
Files:
crates/hero_router/src/server/terminal.rs(module docs)Dependencies: none.
Acceptance Criteria
printf '\e[6n'; IFS=';' read -sdR -p "" row colin a new terminal session does not show^[[<row>;<col>Rin the visible transcript.TERMINAL_LOG=rawenabled the raw log shows, in order:s2c type=pty_outputcarryingESC[6n, thenc2s type=replycarryingESC[<row>;<col>R. Transcript log shows neither.term.onDatacallbacks) is emitted as a singleTerminalReplyenvelope.abc\e[6nxyzyields, in order: oneUserInputabc, oneTerminalReply(CPR answer), oneUserInputxyz.ResizeTerminalEventand updates per-sessionTerminalState.cols/rows.ESC [ = 5 h) is not misclassified as a reply.terminal.reply { name, data_b64 }with an attached session delivers bytes to the PTY and returns{ok: true}; unattached session returns-32000.openrpc.jsonvalidates;rpc.discoverincludesterminal.reply.Notes
vtecrate is added in Phase 1 — xterm.js is authoritative, and the small CPR/DA regex at the edge is incremental-safe./api/terminal/pty/:nameWebSocket must stay backward compatible during rollout (legacy Binary + legacy{"resize":…}text still accepted).terminal.replyis for headless/scripted/agent consumers that don't run xterm.js locally. Browser clients still answer CPR automatically; the difference is the reply is now labeled explicitly on the wire.inject_replyonly succeeds for currently attached sessions. Unattached → clean-32000.TERMINAL_LOG=rawincludes keystrokes — default must stayoff; document this.data_b64fields for binary-clean transport across JSON.Test Results
Ran
cargo test -p hero_router:No new unit tests were added in Phase 1. Acceptance criteria from the spec are behavioural (browser-level CPR roundtrip, resize propagation,
terminal.replyinjection) and require manual verification against a running PTY session. A browser-level test harness for that is out of scope for this issue.Implementation Summary — Phase 1
Branch:
development_tty(not merged, not pushed). All changes are local commits pending review.Files changed
crates/hero_router/src/server/terminal.rsTerminalEventenum (user_input/terminal_reply/resize) deserialised from typed JSON text frames.TerminalState { cols, rows }and a process-wideAsyncMutex<HashMap<String, (TerminalState, ReplySender)>>registry (attached()).inject_reply(name, bytes) -> Result<()>for OpenRPC-driven reply injection.pty_proxyrewritten: registers the session at attach time, spawns abytes -> Binarybridge soinject_replyand the browser'suser_input/terminal_replyshare the same hero_proc sink. Four-waytokio::select!across browser→server, server→browser, reply-bridge, forwarder. Legacy{"resize":...}text and rawBinaryframes continue to work; a single warn-per-connection fires on legacy binary.TERMINAL_LOG=raw|transcript|offenv gate for a separate debug log target. Default off.pty_handlernow passes the sessionnamedown topty_proxy.crates/hero_router/src/server/rpc.rsterminal.reply { name, data_b64 }dispatch arm. Returns{ ok: true }on success,-32602for invalid base64,-32000when no session is attached.crates/hero_router/static/openrpc.jsonterminal.replymethod entry documenting the behaviour and params.crates/hero_router/static/js/terminal.jsbytesToB64andsendEventhelpers.user_input,terminal_reply,resize.matchReply+isReplyPrefix+pendingbuffer) onterm.onDataso CPR replies are labelled, not mixed with keystrokes, even when split across callbacks.ArrayBuffer,Blob, typedpty_outputJSON (defensive, for a future phase), and legacy plain text.?termlog=rawURL flag emitsconsole.debugfor each outbound frame.crates/hero_router/Cargo.tomlbase64 = "0.22".Test results
No new cargo tests were added in Phase 1. The acceptance criteria are browser/PTY-behavioural (CPR roundtrip, chunked reply reassembly, resize state,
terminal.replyinjection into an attached session) and need manual verification against a live PTY. A browser-level test harness for this is out of scope for this issue.Notes & caveats
term.onData-> WebSocket today. If^[[2;8Ris rendered in the visible transcript, it is because hero_proc_server either echoes reply bytes back out the PTY master read side, or the UI accidentally passesonDatabytes intoterm.write(this branch adds a defensive filter against the latter). The hero_proc-side fix (write reply bytes to PTY master stdin only, never surface them on stdout) is tracked separately per the issue's bonus note.{"resize":{...}}text and rawBinaryframes continue to be accepted by the router; a warning is logged once per connection when the browser sends legacy binary. No hero_proc change is required for Phase 1 — the router still sendsBinary(for input and replies) andText {"resize":...}(for resize) to hero_proc exactly as today.terminal.replyis for headless / scripted consumers. Browser clients answer CPR automatically via xterm.js; this RPC is for programmatic clients that open a session over OpenRPC and cannot run a local VT emulator. Callingterminal.replyon a session that is not currently attached returns-32000.vtecrate was added. Phase 1 keeps xterm.js as the authoritative VT emulator; the frontend's CPR/DA regex at the edge is incremental-safe for the handful of supported queries.TERMINAL_LOG=rawincludes keystrokes. It is off by default and documented in the module prose. Operators who enablerawfor debugging should be aware.Test Results — Phase 1 Browser Verification
cargo test: 79 passed, 0 failed
Browser tests (MCP browser against localhost:9988):
terminal.replywith attached session returns{ok: true}terminal.replywith unattached session returns-32000terminal.replywith invalid base64 returns-32602openrpc.jsonincludesterminal.replywith correct paramsrpc.discoverreturnsterminal.replyuser_input/terminal_reply) logged via?termlog=raw{"kind":"resize","cols":189,"rows":51}on attachKnown upstream issues (out of Phase 1 scope):
^[[row;colR) are echoed back by hero_proc as visible output — this is the upstream hero_proc PTY bug documented in the issue. The router correctly classifies and routes the reply; the echo-back fix lives in hero_proc.[3;1H[J) can overwrite short command output at low scroll positions. Unrelated to this PR.No regressions observed in existing router functionality.
Implementation Summary — Phase 1 (development_2 branch)
All Phase 1 changes are present in the
development_2branch. The implementation was verified against a live hero_proc PTY session using browser automation.Files modified:
crates/hero_router/src/server/terminal.rs—TerminalEventenum with typed framing (UserInput,TerminalReply,Resize),TerminalStateregistry,inject_reply(),pty_proxyrewritten with four-waytokio::select!crates/hero_router/src/server/rpc.rs—terminal.reply { name, data_b64 }dispatchcrates/hero_router/static/openrpc.json—terminal.replymethod entrycrates/hero_router/static/js/terminal.js—bytesToB64/sendEventhelpers, typed outbound frames, incremental CPR/DA/DA2 classifier,?termlog=rawconsole debugCargo.toml—base64 = "0.22"dependencyWire compatibility: Legacy raw-binary frames and
{"resize":{...}}text frames are still accepted.