Skip to content
Draft
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
117 changes: 117 additions & 0 deletions docs/terminal-streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Terminal Snapshot Streaming Contracts

Codeman exposes three coordinated sources for restoring a terminal:

1. a bounded rendered tmux history page;
2. a current-pane HTTP snapshot; and
3. live `session:terminal` events arriving over SSE, followed by WebSocket output.

HTTP content encoding remains lossless: Fastify negotiates Brotli or gzip, and the browser
decompresses each response incrementally. The contracts let a client decouple history
download from terminal parsing and avoid requesting the full retained conversation.

## Cursor Contract

Every Session owns a terminal stream id and a lexicographically monotonic cursor:

```ts
interface TerminalCursor {
stream: string;
generation: number;
start: number;
end: number;
}
```

- `stream` changes when the in-memory Session is recreated.
- `generation` increases when the retained terminal buffer is replaced or cleared.
- `start` and `end` are UTF-16 string offsets within that generation.
- A live event covers `[start, end)`.
- A snapshot cursor identifies the live-output boundary represented by the snapshot.

A client can queue live events while a snapshot is loading. Once the snapshot finishes:

- events ending at or before the snapshot boundary are discarded;
- events starting at or after the boundary are replayed;
- a batch crossing the boundary replays only its uncovered suffix;
- events from older generations or a replaced stream are discarded;
- events from a newer generation are replayed in full.

This replaces the previous empty-buffer heuristic, which could either duplicate an Ink
redraw or discard a prompt emitted during the fetch.

## History Page Contract

`GET /api/sessions/:id/terminal?historyPage=1&lines=400&format=stream` returns
one bounded physical-row slice of retained tmux history. It never includes the visible pane.

Page headers:

- `X-Codeman-History-Start` / `End`: half-open absolute row range from the
oldest retained tmux history row.
- `X-Codeman-History-Total`: retained history rows at capture time.
- `X-Codeman-History-More-Before` / `More-After`: navigable directions.
- `X-Codeman-History-Origin`: opaque pane/history-origin fingerprint.

Omitting `before`/`after` returns the newest page. `before=<row>` walks toward
older output; `after=<row>` walks toward newer output. The browser requests 400
rows per page; the server accepts and clamps explicit sizes from 100–2,000
physical rows. Page capture deliberately does not use tmux `-J`, so each page
coordinate remains one physical tmux row.

The tmux commands run asynchronously, so page capture does not block terminal
input, SSE flushes, or other sessions while the subprocess returns.

If tmux page capture fails, the endpoint falls back to the newest 1 MB of the
PTY buffer without page metadata. The client then disables row paging for that
load because byte offsets cannot safely substitute for tmux row coordinates.

`X-Codeman-History-Origin` changes when the pane or oldest retained rows change.
The client rejects a page with a different origin instead of stitching rows
across tmux eviction or pane replacement.

## HTTP Snapshot Contract

`GET /api/sessions/:id/terminal?format=stream` returns raw terminal text rather than the
JSON API envelope. Snapshot metadata is carried in `X-Codeman-Terminal-*` headers. The
existing JSON response remains available for old clients and as a frontend fallback.

`latest=1` projects the same endpoint down to the current rendered tmux pane. It can
provide a bounded first paint while the newest history page loads independently. The
latest response supplies the authoritative PTY cursor for live-event reconciliation.

`full=1` remains available for legacy callers that explicitly require one complete tmux
capture, but the browser no longer uses it during startup or session switching.

The stream response is `no-store`. It may still be reconstructed from tmux history or a
visible pane capture, so cursor offsets order live events; they are not byte offsets into
the normalized response body.

## Live Transport Handoff

SSE and WebSocket can use the same per-tab transport id (`clientId:tabNonce`). When a
WebSocket opens with that id, the server flushes that session's pending SSE batch before
suppressing SSE terminal events for the tab. The WebSocket then becomes the only live
terminal transport for that session.

Active WebSocket output uses a viewport-aware, lossless micro-batch. Desktop and
tablet connections retain the 8 ms / 8 KB low-latency path. A connection that
announces a mobile viewport groups raw PTY output for at most 50 ms and emits
approximately 16 KB frames. The wider phone window combines adjacent decorative
redraws before adding one DEC-2026 synchronized-update pair, reducing full xterm
paints without discarding terminal data.

Bulk data is split on UTF-8- and ANSI-safe boundaries before each payload is
wrapped. A single control sequence, OSC/DCS string, or application-owned
synchronized update remains indivisible even when it exceeds the viewport target;
terminal correctness takes priority over a hard packet limit. If a PTY event ends
inside a control sequence, the connection emits its safe prefix and carries the
incomplete suffix into the next event rather than inserting a synchronization
marker inside the command.

For an intentional disconnect, a client may send a handoff frame and wait briefly for
an acknowledgement. The server flushes pending WebSocket output, detaches its Session
listeners, discards any overlapping SSE batch while the tab is still suppressed, then
resumes SSE and acknowledges the handoff. An unexpected close resumes SSE with a
targeted `session:needsRefresh` event so the client can reload an authoritative snapshot
rather than risk a missing transport interval.
28 changes: 28 additions & 0 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ export interface PaneCaptureOptions {
maxCaptureBytes?: number;
}

/** A stable physical-row slice of a tmux pane's retained history. */
export interface PaneHistoryPage {
/** ANSI-styled physical rows separated by CRLF, excluding the visible pane. */
buffer: string;
/** Inclusive absolute row offset from the oldest retained history row. */
start: number;
/** Exclusive absolute row offset from the oldest retained history row. */
end: number;
/** Retained history rows at capture time, excluding the visible pane. */
total: number;
hasMoreBefore: boolean;
hasMoreAfter: boolean;
/** Changes when the pane or oldest retained history changes. */
origin: string;
}

export interface PaneHistoryPageOptions {
/** Maximum physical tmux rows to return. */
limit: number;
/** Fetch the page ending immediately before this absolute history row. */
before?: number;
/** Fetch the page beginning at this absolute history row. */
after?: number;
}

/**
* Terminal multiplexer interface.
*
Expand Down Expand Up @@ -271,4 +296,7 @@ export interface TerminalMultiplexer extends EventEmitter {
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
*/
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;

/** Capture one bounded physical-row page from the active pane's retained history. */
captureActivePaneHistoryPage?(muxName: string, opts: PaneHistoryPageOptions): Promise<PaneHistoryPage | null>;
}
65 changes: 57 additions & 8 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.j

export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';

/**
* Orders terminal output within one in-memory Session.
*
* Offsets use JavaScript string (UTF-16) units so the browser can slice an
* overlapping SSE batch with the same values without a byte conversion.
*/
export interface TerminalCursor {
stream: string;
generation: number;
start: number;
end: number;
}

/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;

Expand Down Expand Up @@ -310,6 +323,9 @@ export class Session extends EventEmitter {
private _respawnBlocked = false;
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
private readonly _terminalStreamId = uuidv4();
private _terminalGeneration = 0;
private _terminalCursorEnd = 0;
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
private _errorBuffer: string = '';
private _lastActivityAt: number;
Expand Down Expand Up @@ -658,6 +674,15 @@ export class Session extends EventEmitter {
return this._terminalBuffer.length;
}

get terminalCursor(): TerminalCursor {
return {
stream: this._terminalStreamId,
generation: this._terminalGeneration,
start: Math.max(0, this._terminalCursorEnd - this._terminalBuffer.length),
end: this._terminalCursorEnd,
};
}

get textOutput(): string {
return this._textOutput.value;
}
Expand Down Expand Up @@ -1408,10 +1433,10 @@ export class Session extends EventEmitter {
});
}

// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
// BufferAccumulator handles auto-trimming when max size exceeded.
const cursor = this._appendTerminalBuffer(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('terminal', data, cursor);
this.emit('output', data);
}

Expand Down Expand Up @@ -1546,7 +1571,7 @@ export class Session extends EventEmitter {
// Clean the buffer - remove mux init junk before actual content
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
this._replaceTerminalBuffer(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
// Signal client to refresh
this.emit('clearTerminal');
}
Expand Down Expand Up @@ -1901,7 +1926,7 @@ export class Session extends EventEmitter {
if (!isRestored) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
this._clearTerminalBuffer();
this.ptyProcess.write('clear\n');
}
}, 100);
Expand Down Expand Up @@ -2115,7 +2140,7 @@ export class Session extends EventEmitter {

private _resetBuffers(): void {
this._status = 'busy';
this._terminalBuffer.clear();
this._clearTerminalBuffer();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
Expand Down Expand Up @@ -2863,7 +2888,7 @@ export class Session extends EventEmitter {
assignTask(taskId: string): void {
this._currentTaskId = taskId;
this._status = 'busy';
this._terminalBuffer.clear();
this._clearTerminalBuffer();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
Expand All @@ -2889,12 +2914,36 @@ export class Session extends EventEmitter {
}

clearBuffers(): void {
this._terminalBuffer.clear();
this._clearTerminalBuffer();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._taskTracker.clear();
this._ralphTracker.clear();
this._taskCache.clear();
}

private _appendTerminalBuffer(data: string): TerminalCursor {
const start = this._terminalCursorEnd;
this._terminalBuffer.append(data);
this._terminalCursorEnd += data.length;
return {
stream: this._terminalStreamId,
generation: this._terminalGeneration,
start,
end: this._terminalCursorEnd,
};
}

private _replaceTerminalBuffer(data: string): void {
this._terminalBuffer.set(data);
this._terminalGeneration++;
this._terminalCursorEnd = data.length;
}

private _clearTerminalBuffer(): void {
this._terminalBuffer.clear();
this._terminalGeneration++;
this._terminalCursorEnd = 0;
}
}
Loading