Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only

Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.

File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links.
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.

### Remote SSH cases

Expand Down
63 changes: 63 additions & 0 deletions docs/remote-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,69 @@ unreachable host answers "unknown", which also means do not revive. The answer
is cached per session and cleared whenever the pane is next seen alive, so a
stale `true` from one transport drop can never revive the NEXT clean exit.

## Terminal history from the remote tmux

The local pane runs the ssh attach, so it only sees the remote tmux client's
full-screen repaints and keeps a fraction of the scrollback (none after a
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
remote session therefore reads the scrollback rows from the REMOTE pane in one
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
local capture, and a transport failure (ssh exit 255, a timeout, output that is
not the script's) also skips that host for 30 s; a failure of the remote
command itself (a remote tmux session that is gone) affects that session only.
How much history exists is the remote tmux's own `history-limit`, which the
launch command does not set (tmux's default is 2000 lines).

The same round trip also reads the remote SCREEN, in the same tmux command list
as the history, because two seams would otherwise drop rows at the join:

- **Rows that scroll during the round trip.** The local frame is captured only
once the round trip is back, so rows that scrolled off the screen in between
are in neither the remote history nor the local frame (measured before this
read existed: 13 lines lost with a 150 ms round trip and fast output).
- **A shared remote session with a taller client.** Under `window-size latest`
a remote session attached elsewhere is sized by its latest client; when that
client is taller than this pane, the rows above this pane's frame are only
on the remote screen (measured before: 20 rows).

Both are rows of the remote screen above where the local frame begins, so the
load keeps them between the history and the frame (`joinScrollbackAndFrame`,
`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching
its top rows against the screen as text (escape sequences and trailing blanks
ignored, since the two tmuxes need not encode attributes alike); the screen's
trailing blank rows and its last content row, which output in flight can still
change, are left out of the match, the overlap must be at least three rows
with two of them non-blank, and the highest match wins. A placement too low
would REPEAT the rows between it and the true one, so a shorter overlap (a lone
`$` row that happens to match after a spinner row changed) is not trusted.
When the frame cannot be placed (more output scrolled than the screen held, or
nothing matches), the join is history over frame, as without the screen read.
That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a
live region (spinner, composer, footer) pinned to the bottom and inserts new
rows above it, so the frame is not a shifted copy of the screen and is not
placed. A plain shell places. What is left: when the screen repeats a whole
block of rows, the frame can be
placed at the higher copy, which leaves out the rows between the two copies.

The history, the history size and the screen are ONE tmux command list
(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux`
runs. tmux runs a client's command list back to back without reading pane
output in between, so the screen begins exactly where the history ends. As
separate runs the shell kept writing between them and the rows that scrolled in
those milliseconds were in neither capture (measured with output streaming: 1-2
rows per load on a LAN host, every read on a local busy pane). This rests on how
tmux's command queue behaves, not on a documented guarantee. A random marker row
(`display-message`) separates the history from the size and the screen, since
tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no
newline after `capture-pane -p` output, 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. The remote `tail -c` bound
applies to that whole output, history first, and is the configured byte cap
plus 1 MiB for the marker, size and screen, so a cut lands in the history; the
route trims what it returns to the cap itself.

## File access over SSH

A remote case's `workingDir` is an absolute path on the **remote** host
Expand Down
15 changes: 15 additions & 0 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,21 @@ export interface PaneCaptureOptions {
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
/**
* Full-history only: scrollback rows read from somewhere other than this pane
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
* only its visible frame and caret, and its own history is not read. A remote
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
*/
scrollbackOverride?: string;
/**
* With `scrollbackOverride`: the screen rows of the pane that history came
* from, read right after it. The frame this pane captures later can have
* scrolled past some of them, or show only part of a taller screen, so the
* rows above where the frame's top overlaps them are kept between the
* scrollback and the frame (`joinScrollbackAndFrame`).
*/
scrollbackOverrideScreen?: string;
/**
* Byte cap the consumer will keep from the capture. Sizes the child-process
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
Expand Down
Loading
Loading