Repository navigation
Conversation
A remote SSH session's local pane runs `ssh -t … tmux new-session -A` (or
`attach`), so the local tmux only sees the remote tmux client's full-screen
repaints and keeps a fraction of the history: measured 48 of 279 lines while
output streamed, none after a reattach. The remote pane, where the shell
runs, keeps all of it.
A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a remote
session now reads the scrollback rows from the remote pane in one ssh round
trip (`#{history_size}` + `capture-pane -S -<N> -E -1`, exact `=name:`
target, newest bytes kept by a remote `tail -c`), and the local capture
reads only its visible frame (`-S 0`), spliced below them
(`PaneCaptureOptions.scrollbackOverride`). The local frame and caret are
kept because the live stream keeps drawing into the local pane.
Fail-soft and bounded: connection options from buildSshConnectionArgs(),
the shared remote-ssh limiter (a slot waited for longer than 1.5 s is given
up), a 5 s timeout, and a 30 s per-host back-off after any failure, which
keeps the local capture as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f only on transport failures Review follow-ups for the remote scrollback read. The 1.5 s slot-wait bound was checked inside the task, so it only fired once runWithRemoteSshLimit() had already handed over a slot, after everything queued ahead of it. runWithRemoteSshLimit() now takes `maxWaitMs`: a waiter that times out leaves the queue, rejects with RemoteSshSlotTimeoutError, never runs and never touches `active`. The history read uses it, and a slot timeout does not back the host off. Only a transport failure backs the host off now (ssh exit 255, a timeout, a spawn error, output that is not the script's), via classifyRemoteAliveExit(). A remote command failing is about that session only. A live run against a real host showed a missing session comes back as exit 0 with empty markers, because the `| tail -c` pipe takes tail's exit status, so markers that arrived without a size are also per-session. fetchRemoteScrollback() takes an optional runner as a test seam; tests now cover the bounded wait, each back-off class, its expiry, and the masked missing-session case. A source pin covers the `-S 0` capture branch. The two seams that can drop rows at the join (rows that scroll during the round trip, a taller shared client) are documented in docs/remote-sessions.md, and the limiter's header and the remote-ssh invariants now name the history read as an ssh consumer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…emote history load The remote history and the local frame are read a round trip apart, so two seams dropped rows at the join: rows that scrolled off the screen during the round trip, and the top of a remote screen taller than this pane (a shared session sized by a taller client under window-size latest). Both are rows of the remote screen above where the local frame begins. The remote script now also captures the screen, right after the history, in the same round trip. joinScrollbackAndFrame() places the local frame among the screen rows (findFrameInScreen(): text compare with escape sequences and trailing blanks ignored, the screen's trailing blank rows and last content row left out, at least three rows of overlap with two of them non-blank, highest match wins) and keeps the screen rows above it between the history and the frame. When the frame cannot be placed, the join is history over frame as before. The read's buffer slack grows to 1 MiB, since the screen is outside the tail -c bound. Left: the history and screen are two tmux commands, so a row that scrolls between them (milliseconds) is lost, and a screen that repeats a whole block can place the frame at the higher copy, losing the rows between the copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote script ran the history capture, the history size and the screen capture as separate tmux runs, so the shell kept writing between them and rows that scrolled in those milliseconds were in neither capture. Measured end to end against a LAN host with output streaming: 1-2 rows lost per load at the history/screen join (the history ended at row N, the screen started at N+2); on a local busy pane, every read. The three reads are now one tmux command list (capture \; display \; display \; capture). tmux runs a client's command list back to back without reading pane output in between, so the screen begins where the history ends: 0 gaps in 200 reads of a local busy pane, and no gaps or duplicates in 8 loads against the LAN host. This rests on tmux's command-queue behaviour, not a documented guarantee. tmux output cannot carry NULs, so a random marker row per read (display-message) separates the history from the size and the screen; the parser takes it only as a whole row. The NULs around the tmux output are printed whether or not tmux succeeds, so a missing session still reads as the script having run (no host back-off), with its own log text when tmux printed something unparseable. The remote tail -c bound now covers the whole output, history first, and is the configured cap plus 1 MiB for the marker, size and screen (remoteTailBytes), so a cut lands in the history; the route trims to the cap itself. A cut landing exactly on the marker row keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks @timkjr for this follow-up to #602. It closes the row gaps at the join in a remote history load: the remote screen is read in the same round trip, the screen rows above the local frame are spliced in, and the history, size and screen come from one tmux command list so nothing scrolls between them. On the design question you raised: the command list is the right call, no rework needed. I reproduced it on tmux 3.4 with an unthrottled output loop: one command list gave 0 of 100 reads with a gap at the history/screen boundary, separate tmux runs gave 100 of 100. If a future tmux ever yields between commands, the cost is #602's behaviour, not a failure. Two things before merge: 1. tmux older than 3.2 breaks the marker parsing (
Either fix works:
Please add a parser test with the pre-3.2 output shape. The "finds the marker only as a whole row" test ( 2. Document that the splice does not engage for agent TUIs mid-turn (
Everything else checks out: typecheck, lint, format, the full Next steps: #602 lands first. Once it has, please rebase this branch onto master so it carries only your two commits, and include the two items above. Then it is ready to merge. |
tmux before 3.2 prints no newline after `capture-pane -p` output, and `-J` leaves the history's last row unterminated when the screen starts inside a wrapped line. The marker row printed next is then glued onto that row (`...first half<marker>\n31\n...`). The parser took the marker only as a whole row, so it returned null and every load fell back to the local capture while the screen stayed that way. Debian 11, Ubuntu 20.04 and RHEL 8 ship tmux 3.1c, 3.0a and 2.7. The parser now takes the first `<marker>\n` wherever it ends a row (the marker is random per read, so no row the pane holds ends with it) and re-terminates a history that lacks its newline. A tail -c cut still drops only the history's partial first row, now decided on the whole output, so a cut inside the one row the marker is glued to leaves no history instead of losing the marker. dropPartialFirstRow, no longer called, is removed. Docs and the findFrameInScreen JSDoc also say the splice does not place the frame of an agent TUI mid-turn (a live region pinned to the bottom, new rows inserted above it), which falls back to the history-over-frame join. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for reproducing the command list on 3.4. Both items are in 8d92e57, on top of the two commits.
I'll rebase onto master once #602 has landed, so this carries only its own commits. Typecheck, lint, format, frontend syntax, browser-excludes and the full |
Summary
#602 loads a remote session's scrollback from the remote tmux and puts it above the local frame. Its docs record two seams where rows can drop out of the join:
window-size latestthe remote screen can be taller than this pane, and the rows above this pane's frame exist only on the remote screen.This PR closes both, and a third gap found while testing it end to end.
1. Keep the remote screen rows above the local frame (
28b41efa)The remote script also captures the remote SCREEN in the same round trip.
joinScrollbackAndFrame()places the local frame among the screen rows (findFrameInScreen()) and keeps the screen rows above it between the history and the frame:2. Read the history and the screen atomically (
8e2594a2)An end-to-end run (below) showed the first commit still dropped 1-2 rows per load while output streamed. The cause was in the remote script: the history and the screen were two separate
tmuxruns, and rows that scrolled between them in those milliseconds were in neither capture. A remote read showed this directly: the history ended at row N and the screen started at N+2.The history capture, the history size and the screen capture are now ONE tmux command list:
codeman-history-<16 hex>, new per read) separates the history from the size and screen. tmux before 3.2 prints no newline aftercapture-pane -p, so when the history ends inside a wrapped line the marker is glued onto that row; the parser accepts the marker at the end of a row and re-terminates the history (8d92e577).tail -cbound now covers the whole tmux output, history first. It is the configured byte cap plus 1 MiB for the marker, size and screen (remoteTailBytes()), so a small cap cannot cut into the screen and a cut lands in the history, whose partial first row is dropped as before. The route already trims what it returns to the cap, keeping the newest bytes.Decision for the maintainer: this relies on tmux's command-queue behaviour
Atomicity here rests on how tmux runs a client's command list: it runs the commands back to back without reading pane output in between. That behaviour is measured below, but tmux does not document it as a guarantee. If a future tmux yields between commands in a list, the gap comes back. It would be 1-2 rows under streaming output, and nothing worse than #602 alone.
Measured on a local tmux with an unthrottled output loop, 200 reads each:
tmuxrunsAlternatives considered:
capture-pane -S -<N>through the bottom of the screen, split by#{pane_height}. It is atomic by construction. But-Jjoins wrapped rows, so the screen's row count in the output is notpane_height, and the split is inexact exactly when lines wrap. The join then needs the frame matched against the whole capture rather than the screen.The command list was chosen because it keeps the exact history/screen boundary and the existing join. Whether depending on tmux's queue behaviour is acceptable, or one of the alternatives fits better, is the maintainer's call; the PR can be reworked to any of them.
End-to-end test (beta instance, real remote host on the LAN)
Remote shell on a Proxmox host over ssh, about 170 ms per load. Each load is
GET /api/sessions/:id/terminal?full=1&tail=…&lines=10000, and the rows are numbered so gaps and duplicates can be counted.136f1154)With unthrottled output, one load lost 2,502 rows: the output outran the 40-row screen during the round trip, so the frame had nothing to match and the join fell back to history over frame. That is the documented no-match case, and the same as #602.
Not tested live: a remote screen taller than the local pane (the beta session takes the local pane's size). That path is covered by unit tests only.
Still open
Tests
test/remote-pane-history.test.ts:/bin/shwith a stubtmuxthat executes a;command list. It checks there is ONE tmux run, the exact argv in order, atail -ccut dropping the partial row, and both NULs printed when tmux fails.remoteTailBytes()adds the screen allowance and keeps 0 unbounded.test/routes/session-routes.test.ts: the route passes the screen through.npm test(10707 passed, 12 skipped, 0 failed).🤖 Generated with Claude Code