diff --git a/CLAUDE.md b/CLAUDE.md index e6efbd1c5..522c53b53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection always starts from a bounded 1 MiB `?tail=` window and loads the rest only when **Load full history** is pressed. Ordinary Shell scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`. @@ -413,7 +413,7 @@ Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/scree Target: 20 sessions, 50 agent windows at 60fps. Limits live in `src/config/` (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable. -Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is **clamped to ≤75% of max**, because a trim ≥ max would disable `BufferAccumulator` trimming entirely and make memory unbounded; and browser xterm scrollback is a **separate hardcoded 50k** (`DEFAULT_SCROLLBACK` in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. The settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but **inert**; only `tmuxHistoryLimit` is wired live. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history), `docs/terminal-anti-flicker.md` +Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is **clamped to ≤75% of max**, because a trim ≥ max would disable `BufferAccumulator` trimming entirely and make memory unbounded; and browser xterm scrollback is a **separate hardcoded 50k** (`DEFAULT_SCROLLBACK` in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. tmux <3.7 allocates `history-limit` at pane creation, while tmux 3.7+ can resize live panes (lowering the value can discard retained lines); already-evicted lines never return. The settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but **inert**; only `tmuxHistoryLimit` is wired. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history), `docs/terminal-anti-flicker.md` **Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Verify: `npm test -- test/memory-leak-prevention.test.ts`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 75be306f6..7992d5c3a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -92,7 +92,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is therefore explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`. ### Terminal scrollback: strip flavors and wheel/touch forwarding @@ -359,7 +359,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. ## Local packages and build artifacts diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 5a0cac5d7..614c9521c 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -133,8 +133,10 @@ TUIs render correctly. Worth knowing: -- **Scrollback.** The first time you open a session, Codeman pulls the entire tmux - scrollback, not just the recent tail. Scrolling to the very top pulls again on demand. +- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open. + Shell sessions open from a bounded recent tail so a large transcript cannot stall tab + switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling + stays within the bounded browser buffer so dragging upward remains responsive. - **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is always local scrollback. Other CLIs scroll locally. diff --git a/src/config/terminal-history.ts b/src/config/terminal-history.ts index 95575b7a2..a91abf3ec 100644 --- a/src/config/terminal-history.ts +++ b/src/config/terminal-history.ts @@ -8,7 +8,8 @@ * src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab * is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match. * The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys - * remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live. + * remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is wired. + * tmux <3.7 applies it to new panes; tmux 3.7+ can also resize live panes. * All values remain env- and settings-overridable and bounds-clamped via * resolveTerminalHistoryConfig(). */ diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 04c1b1416..0368c92b2 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -84,7 +84,7 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; - /** tmux history-limit (scrollback lines) to set for this session. */ + /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; @@ -116,7 +116,7 @@ export interface RespawnPaneOptions { envOverrides?: Record; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; - /** tmux history-limit (scrollback lines) to set for this session after respawn. */ + /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; @@ -216,7 +216,7 @@ export interface TerminalMultiplexer extends EventEmitter { /** Update Ralph enabled state for a session */ updateRalphEnabled(sessionId: string, enabled: boolean): void; - /** Apply a tmux history-limit to all tracked sessions. */ + /** Apply history-limit to live panes where tmux supports it, otherwise to future panes. */ setHistoryLimit(limit: number): Promise; // ========== Discovery ========== diff --git a/src/session.ts b/src/session.ts index 42dcac62d..bfb1b8e72 100644 --- a/src/session.ts +++ b/src/session.ts @@ -510,7 +510,7 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; - // tmux history-limit (scrollback lines) applied to this session's pane. + // tmux history-limit (scrollback lines) allocated when this session's pane is created. private readonly _tmuxHistoryLimit: number; // Remote execution metadata, present when this session runs over SSH through local tmux. @@ -600,7 +600,7 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; - /** tmux history-limit (scrollback lines) for this session's pane. */ + /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ attachmentHistory?: SessionAttachmentHistoryItem[]; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 1c781a235..925b6aacf 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1562,6 +1562,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { private reconnectGuard: Set = new Set(); private trueColorConfigured = false; + /** tmux 3.7+ can resize pane history after creation; older releases cannot. */ + private liveHistoryResizeSupported: boolean | null = null; constructor() { super(); @@ -1580,6 +1582,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return tmuxCommand(this.tmuxSocket); } + private supportsLiveHistoryResize(): boolean { + if (this.liveHistoryResizeSupported !== null) return this.liveHistoryResizeSupported; + + try { + const output = execSync(`${this.tmux()} -V`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/(?:^|\D)(\d+)\.(\d+)/); + const major = match ? Number(match[1]) : 0; + const minor = match ? Number(match[2]) : 0; + this.liveHistoryResizeSupported = major > 3 || (major === 3 && minor >= 7); + } catch { + // Unknown versions take the legacy path required by tmux <3.7. + this.liveHistoryResizeSupported = false; + } + return this.liveHistoryResizeSupported; + } + // Load saved sessions from disk (NEVER called in test mode) private loadSessions(): void { if (IS_TEST_MODE) return; @@ -1921,7 +1943,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone // mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The // pane cd's into workingDir below via respawn-pane. - execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, { + // tmux <3.7 allocates history only at pane creation, so its global default + // must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane + // after creation; target only the new session there because changing the + // global option can resize (and when lowered, trim) unrelated live panes. + const safeHistoryLimit = + Number.isSafeInteger(historyLimit) && historyLimit > 0 ? Math.trunc(historyLimit) : DEFAULT_TMUX_HISTORY_LIMIT; + const createSessionCommand = this.supportsLiveHistoryResize() + ? `${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}` + : `${this.tmux()} set-option -g history-limit ${safeHistoryLimit} \\; new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`; + execSync(createSessionCommand, { cwd: TMUX_LAUNCH_CWD, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', @@ -1986,16 +2017,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { .catch(() => { /* Already set globally as fallback */ }), - // Raise tmux scrollback from its 2000-line default so re-attach preserves - // more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k - // in constants.js), which stays lower to protect browser/mobile memory. - execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, { - timeout: EXEC_TIMEOUT_MS, - }) - .then(() => {}) - .catch(() => { - /* Non-critical — falls back to tmux default */ - }), ]; // Enable 24-bit true color passthrough — server-wide, set once per lifetime @@ -2119,7 +2140,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, - historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, name, @@ -2130,16 +2150,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; - // Re-apply the configured tmux history-limit after respawn (kept in sync - // with the live setting via setHistoryLimit()). - if (!IS_TEST_MODE) { - await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, { - timeout: EXEC_TIMEOUT_MS, - }).catch(() => { - /* Non-critical — keeps existing tmux history-limit */ - }); - } - // Resolve CLI binary directory based on mode const { pathExport } = this.buildPathExport(mode); @@ -2998,9 +3008,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } /** - * Apply a tmux history-limit to all tracked sessions (e.g. when the user - * changes the terminal-history setting). Invalid limits fall back to the - * default. Best-effort per session. + * Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions; + * older releases can only change the global default for future panes. Invalid + * limits fall back to the default. */ async setHistoryLimit(limit: number): Promise { const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT; @@ -3009,12 +3019,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return; } - const updates = Array.from(this.sessions.values()).map((session) => - execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, { - timeout: EXEC_TIMEOUT_MS, - }) - ); - await Promise.allSettled(updates); + if (this.supportsLiveHistoryResize()) { + const updates = Array.from(this.sessions.values()).map((session) => + execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, { + timeout: EXEC_TIMEOUT_MS, + }) + ); + await Promise.allSettled(updates); + return; + } + + await execAsync(`${this.tmux()} set-option -g history-limit ${safeLimit}`, { + timeout: EXEC_TIMEOUT_MS, + }).catch(() => { + // No tmux server yet is fine: legacy createSession sets the same default + // immediately before it creates the first pane. + }); } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index d7cc0d8ad..1c78ee62e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -536,10 +536,10 @@ class CodemanApp { this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads - // Sessions whose full tmux scrollback has already been replayed this page load - // (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the - // flag was consumed by whichever session auto-selected at page load, so every - // OTHER tab started life with one visible frame of history (issue #205). + // Non-shell sessions whose full tmux scrollback has already been replayed this + // page load (COD-47). Shells deliberately start from a bounded tail because + // their scrollback can be very large; full history stays available on demand. + // Tracked PER SESSION rather than as a single "first load" flag (issue #205). this._fullHistoryLoaded = new Set(); // Cooldown per session for the scroll-to-top "load more history" re-pull. this._fullHistoryRepullAt = new Map(); // Map @@ -5268,6 +5268,22 @@ class CodemanApp { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } + _recordTerminalLoadTiming(timing) { + this._lastTerminalLoadTiming = timing; + console.info('[TERMINAL-PERF]', timing); + const resetAndParseMs = + (timing.cacheResetAndParseMs || 0) + + (timing.freshResetAndParseMs || 0) + + (timing.resetAndParseMs || 0); + const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0; + _crashDiag.log( + `TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` + + `ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` + + `reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` + + `server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}` + ); + } + /** * "Load more history": re-pull the whole tmux scrollback when the user scrolls up * while already at the top of what the browser has. @@ -5296,6 +5312,11 @@ class CodemanApp { const sessionId = this.activeSessionId; if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; if (this.detachedSessions?.has(sessionId)) return; + const session = this.sessions.get(sessionId); + // A shell's full capture can be many megabytes. Replaying it from an + // ordinary scroll gesture blocks xterm's main thread, so keep that cost + // behind the explicit "Load full history" button. + if (!force && session?.mode === 'shell') return; const now = Date.now(); // Momentum scrolling fires this dozens of times per flick, and a burst of new // output is the normal reason to want a re-pull, so cooldown rather than latch. @@ -5307,13 +5328,32 @@ class CodemanApp { this._fullHistoryRepullAt.set(sessionId, now); this._fullHistoryRepullInFlight = true; try { + const requestStartedAt = performance.now(); const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + const headersReceivedAt = performance.now(); const payload = (await res.json())?.data ?? {}; + const bodyParsedAt = performance.now(); const buffer = payload.terminalBuffer; + const timing = { + trigger: force ? 'full-history-button' : 'full-history-scroll', + mode: session?.mode || 'unknown', + full: true, + source: payload.source || 'unknown', + chars: buffer?.length || 0, + ttfbMs: headersReceivedAt - requestStartedAt, + bodyAndJsonMs: bodyParsedAt - headersReceivedAt, + resetAndParseMs: 0, + totalMs: 0, + serverTiming: res.headers?.get?.('server-timing') || '', + refused: false, + }; // Bail on a tab switch mid-fetch: writing here would paint another session's // history into the terminal the user is now looking at. if (!buffer || this.activeSessionId !== sessionId) return; if (this._replayWouldShrinkBuffer(buffer)) { + timing.refused = true; + timing.totalMs = performance.now() - requestStartedAt; + this._recordTerminalLoadTiming(timing); (this._fullHistoryRepullUseless ||= new Set()).add(sessionId); this._logScrollRouting?.('repull-refused-downgrade'); // The browser already holds more than tmux can give back, so there is @@ -5324,17 +5364,32 @@ class CodemanApp { this._setHistoryTruncation(sessionId, payload); this._fullHistoryRepullUseless?.delete(sessionId); const rowsBefore = this.terminal.buffer.active.length; + const replayStartedAt = performance.now(); this._resetTerminalForReplay(); - await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); - if (this.activeSessionId !== sessionId) return; - this.terminalBufferCache.set(sessionId, buffer); + const { + parsedAt, + bufferLength: parsedBufferLength, + completed, + } = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); + timing.resetAndParseMs = parsedAt - replayStartedAt; + if (!completed || this.activeSessionId !== sessionId) return; + // Keep shell tab restores bounded too. A user-triggered full-history pull + // may be tens of MB; caching it would replay that whole payload again on + // the next tab switch before the normal 1MB tail fetch replaces it. + if (this.sessions.get(sessionId)?.mode !== 'shell') { + this.terminalBufferCache.set(sessionId, buffer); + } else { + this.terminalBufferCache.delete(sessionId); + } // Hold the user's place. The replay is a superset that grew the buffer // UPWARD, so what used to be row 0 (what they were looking at) is now `delta` // rows down; scrolling there reveals the recovered history above it instead // of teleporting them to the bottom the way a normal buffer load does. - const delta = this.terminal.buffer.active.length - rowsBefore; + const delta = parsedBufferLength - rowsBefore; if (delta > 0) this.terminal.scrollToLine(delta); else this.terminal.scrollToTop(); + timing.totalMs = performance.now() - requestStartedAt; + this._recordTerminalLoadTiming(timing); } catch { // Transient (offline, 5xx) — the next scroll-up past the cooldown retries. } finally { @@ -5614,6 +5669,7 @@ class CodemanApp { // COD-144: track whether the load painted nothing (empty fetch + no cache). // For that just-created-session case we flush (not discard) queued SSE events. let bufferWasEmpty = false; + let cacheResetAndParseMs = 0; try { // Fit terminal to container BEFORE writing any buffer data. // If the browser was resized while viewing another session, the terminal @@ -5691,23 +5747,30 @@ class CodemanApp { // blank and rewrites with fresh data. Skip the cache and write the fresh // buffer once for a single clean transition. const cachedBuffer = this.terminalBufferCache.get(sessionId); - let clearedForBusy = false; - if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) { + let clearedBeforeFresh = false; + if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') { _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); + const cacheReplayStartedAt = performance.now(); this._resetTerminalForReplay(); - await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner); + const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite( + cachedBuffer, + TERMINAL_CHUNK_SIZE, + bufferLoadOwner + ); + cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt; if (this._isStaleSelect(selectGen)) { this._clearTerminalLoadState(sessionId, selectGen); return; } this.terminal.scrollToBottom(); _crashDiag.log('CACHE_DONE'); - } else if (sessionIsBusy) { - // Clear stale content immediately — fresh buffer is being fetched + } else if (sessionIsBusy || session?.mode === 'shell') { + // Busy sessions have stale caches. Shell sessions deliberately skip even + // an idle cache so a changed 1MB tail cannot cause two back-to-back parses. this._resetTerminalForReplay(); - clearedForBusy = true; - _crashDiag.log('CACHE_SKIP_BUSY'); + clearedBeforeFresh = true; + _crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY'); } // Give TUI sessions a short chance to redraw after resize before the @@ -5725,26 +5788,29 @@ class CodemanApp { this._setTerminalLoadState(sessionId, selectGen, 'fetching'); _crashDiag.log('FETCH_START'); - // The first load OF EACH SESSION this page load requests the full tmux - // scrollback (?full=1, COD-47) so history that scrolled off the server's byte - // buffer comes back. Later switches to an already-replayed session keep the - // fast ?tail= frame path, which is why this is a Set and not a flag: the flag - // version gave the full replay to the auto-selected tab and one frame of - // history to every other one (issue #205). - const useFullHistory = !this._fullHistoryLoaded.has(sessionId); + // TUI sessions still get one canonical full replay per page (COD-47/#205). + // A shell can retain hundreds of thousands of plain scrollback lines, so + // automatically replaying all of them makes tab selection scale with the + // entire session. Load its bounded 1MB tail first; the existing truncation + // banner action fetches ?full=1 when the user explicitly asks for it. + const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId); if (useFullHistory) this._fullHistoryLoaded.add(sessionId); + const fetchStartedAt = performance.now(); const res = await fetch( useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}` ); + const headersReceivedAt = performance.now(); if (this._isStaleSelect(selectGen)) { this._clearTerminalLoadState(sessionId, selectGen); return; } const data = (await res.json())?.data ?? {}; + const bodyParsedAt = performance.now(); _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); + let freshResetAndParseMs = 0; if (data.terminalBuffer) { // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash. // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is @@ -5753,10 +5819,11 @@ class CodemanApp { // something other than the cache, so the fetched buffer must be // replayed even when it byte-matches the cache. const needsRewrite = - restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer; + restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer; if (needsRewrite) { _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); + const replayStartedAt = performance.now(); this._resetTerminalForReplay(); // Truncation is reported OUT OF BAND (#258). This used to write a grey // "... earlier output truncated ..." line into the @@ -5764,7 +5831,12 @@ class CodemanApp { // cannot be actioned, and is indistinguishable from real CLI output. this._setHistoryTruncation(sessionId, data); // Use chunked write for large buffers to avoid UI jank - await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner); + const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite( + data.terminalBuffer, + TERMINAL_CHUNK_SIZE, + bufferLoadOwner + ); + freshResetAndParseMs = freshParsedAt - replayStartedAt; if (this._isStaleSelect(selectGen)) { this._clearTerminalLoadState(sessionId, selectGen); return; @@ -5773,22 +5845,42 @@ class CodemanApp { this.terminal.scrollToBottom(); } - // Update cache (cap at 20 entries) - this.terminalBufferCache.set(sessionId, data.terminalBuffer); - if (this.terminalBufferCache.size > 20) { - // Evict oldest entry (first key in Map iteration order) - const oldest = this.terminalBufferCache.keys().next().value; - this.terminalBufferCache.delete(oldest); + // Shell selection always uses a fresh bounded tail, so retaining its + // payload only wastes memory and can evict useful TUI caches. + if (session?.mode === 'shell') { + this.terminalBufferCache.delete(sessionId); + } else { + // Update cache (cap at 20 entries) + this.terminalBufferCache.set(sessionId, data.terminalBuffer); + if (this.terminalBufferCache.size > 20) { + // Evict oldest entry (first key in Map iteration order) + const oldest = this.terminalBufferCache.keys().next().value; + this.terminalBufferCache.delete(oldest); + } } - } else if (!cachedBuffer) { - // No fresh buffer and no cache — clear any stale content - this._resetTerminalForReplay(); + } else if (!cachedBuffer || clearedBeforeFresh) { + // Nothing was painted. If this path was not already cleared above, + // clear stale content now; either way queued live output must be flushed. + if (!clearedBeforeFresh) this._resetTerminalForReplay(); bufferWasEmpty = true; } + const terminalLoadTiming = { + trigger: 'session-select', + mode: session?.mode || 'unknown', + full: useFullHistory, + source: data.source || 'unknown', + chars: data.terminalBuffer?.length || 0, + ttfbMs: headersReceivedAt - fetchStartedAt, + bodyAndJsonMs: bodyParsedAt - headersReceivedAt, + cacheResetAndParseMs, + freshResetAndParseMs, + selectToReplayCompleteMs: performance.now() - _selStart, + serverTiming: res.headers?.get?.('server-timing') || '', + }; // Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls - // _finishBufferLoad internally (discarding queued events to prevent duplicate - // content); if we skipped the write (cache hit or empty), call it here. + // _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped + // the write (cache hit or empty), call it here. // COD-144: when the load painted nothing, FLUSH the queued events instead of // discarding — a new session's prompt arrives only as a queued SSE event. if (this._isLoadingBuffer) { @@ -5917,9 +6009,12 @@ class CodemanApp { if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) { KeyboardHandler.onKeyboardShow(); } + const selectDoneMs = performance.now() - _selStart; + terminalLoadTiming.selectDoneMs = selectDoneMs; + this._recordTerminalLoadTiming(terminalLoadTiming); this._clearTerminalLoadState(sessionId, selectGen); - _crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`); - console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`); + _crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`); + console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`); } catch (err) { if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner); this._restoringFlushedState = false; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ed5079353..e66fc84aa 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3012,12 +3012,12 @@ Object.assign(CodemanApp.prototype, { /** * Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the - * buffer while scrolling up is the user reaching for history the browser does - * not have, so pull the rest of tmux's scrollback (issue #205, see - * _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the - * check is on the resulting position, and it is deliberately not folded into - * _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare - * per scroll event, and the pull itself is cooldown-guarded. + * buffer while scrolling up gives the app a chance to pull the rest of tmux's + * scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline + * automatic pulls because their captures can be large; their banner button is + * the explicit path. Must be called AFTER scrollLines(), since the check is on + * the resulting position, and it is deliberately not folded into + * _noteTerminalUserScroll for exactly that reason. */ _maybeLoadMoreHistoryOnScroll(lines) { if (lines >= 0) return; @@ -3571,7 +3571,9 @@ Object.assign(CodemanApp.prototype, { } const buffer = this.terminal.buffer.active; - const totalLines = buffer.baseY + buffer.length; + // `length` already includes scrollback + viewport rows. Adding baseY scans + // every scrollback row twice, starting with thousands of out-of-range calls. + const totalLines = buffer.length; let lastNonEmptyLine = -1; for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) { @@ -3601,8 +3603,8 @@ Object.assign(CodemanApp.prototype, { * Uses _safeYield to spread work across frames; falls back to setTimeout * and a tick-Worker so progress continues on occluded / idle-throttled tabs. * @param {string} buffer - The full terminal buffer to write - * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) - * @returns {Promise} - Resolves when all chunks written + * @param {number} chunkSize - Size of each chunk (default 32KB) + * @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot */ chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) { // Generation counter: if a newer chunkedTerminalWrite starts (tab switch), @@ -3611,9 +3613,14 @@ Object.assign(CodemanApp.prototype, { const bufferLoadOwner = this._beginBufferLoad(loadOwner); return new Promise((resolve) => { + const parseSnapshot = (completed = this._chunkedWriteGen === writeGen) => ({ + parsedAt: performance.now(), + bufferLength: this.terminal?.buffer?.active?.length ?? 0, + completed, + }); if (!buffer || buffer.length === 0) { this._finishBufferLoad(bufferLoadOwner); - resolve(); + resolve(parseSnapshot()); return; } @@ -3621,54 +3628,46 @@ Object.assign(CodemanApp.prototype, { // (from historical SSE data that was stored with markers) const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); - const finish = () => { - // Only finish if we're still the active write — a newer write owns buffer load state - if (this._chunkedWriteGen === writeGen) { - this._finishBufferLoad(bufferLoadOwner); - } - resolve(); - }; - // For small buffers, write directly — single-frame render is fast enough if (cleanBuffer.length <= chunkSize) { - this.terminal.write(cleanBuffer, finish); + this.terminal.write(cleanBuffer, () => resolve(parseSnapshot())); + // The write is now ordered in xterm's queue. Release live output before + // parsing completes; subsequent writes stay behind it without being lost. + this._finishBufferLoad(bufferLoadOwner); return; } - // Large buffers: write in chunks across animation frames. - // Each 32KB chunk keeps per-frame WebGL render work under ~5ms, - // avoiding GPU stalls without needing to toggle the renderer. + // Large buffers: enqueue paced chunks, then append an empty marker whose + // callback fires after xterm parses every preceding chunk. The live-output + // gate is released as soon as that marker is ordered, not after parsing, so + // new output queues behind history instead of being held or dropped. let offset = 0; const _chunkStart = performance.now(); let _chunkCount = 0; const writeChunk = () => { // Abort if a newer chunked write started (user switched tabs) if (this._chunkedWriteGen !== writeGen) { - resolve(); + resolve(parseSnapshot(false)); return; } + const chunk = cleanBuffer.slice(offset, offset + chunkSize); + offset += chunk.length; + _chunkCount++; + this.terminal.write(chunk); if (offset >= cleanBuffer.length) { - const _totalMs = performance.now() - _chunkStart; - console.log( - `[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total` - ); - // Wait one more frame for xterm to finish rendering before resolving - this._safeYield(finish); + this.terminal.write('', () => { + const result = parseSnapshot(); + const _totalMs = result.parsedAt - _chunkStart; + console.log( + `[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms parsed` + ); + resolve(result); + }); + this._finishBufferLoad(bufferLoadOwner); return; } - const _ct0 = performance.now(); - const chunk = cleanBuffer.slice(offset, offset + chunkSize); - this.terminal.write(chunk); - const _cdt = performance.now() - _ct0; - _chunkCount++; - if (_cdt > 50) - console.warn( - `[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})` - ); - offset += chunkSize; - // Schedule next chunk; rAF if possible, else setTimeout/Worker // fallback so progress doesn't stall on occluded/unfocused windows. this._safeYield(writeChunk); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 98ea4e7cf..a256cf2fa 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -12,6 +12,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { ApiErrorCode, createErrorResponse, @@ -2270,18 +2271,17 @@ export function registerSessionRoutes( // Query params: // tail= - Only return last N bytes (faster initial load) - // full=1 - Full page reload: replay the entire tmux scrollback (COD-47) - app.get('/api/sessions/:id/terminal', async (req) => { + // full=1 - Explicitly request the entire tmux scrollback (COD-47) + app.get('/api/sessions/:id/terminal', async (req, reply) => { + const routeStartedAt = performance.now(); const { id } = req.params as { id: string }; const query = req.query as { tail?: string; full?: string }; const session = findSessionOrFail(ctx, id, req); - // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded - // the page and wants the whole scroll history back, so we capture the ENTIRE - // tmux scrollback and the user gets back history that scrolled off Codeman's - // byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy - // no-param callers (response-viewer fallback, clearTerminal refresh) — keep - // the fast visible-frame capture. + // `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE + // tmux scrollback so history beyond the server byte buffer can be recovered. + // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy + // no-param callers — keep the fast visible-frame capture. const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const isFullReload = query.full === '1' || query.full === 'true'; const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig(); @@ -2294,6 +2294,7 @@ export function registerSessionRoutes( // overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and // returns null when unavailable, in which case we fall back to history. const muxName = session.muxName; + const captureStartedAt = performance.now(); const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' ? ctx.mux.captureActivePaneBuffer( @@ -2303,6 +2304,7 @@ export function registerSessionRoutes( : undefined ) : null; + const captureFinishedAt = performance.now(); const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0; const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer ? isFullReload @@ -2406,6 +2408,14 @@ export function registerSessionRoutes( // Remove Ctrl+L and leading whitespace (cheap on tailed subset) cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); + const finishedAt = performance.now(); + reply.header( + 'Server-Timing', + `capture;dur=${(captureFinishedAt - captureStartedAt).toFixed(1)}, ` + + `prepare;dur=${(finishedAt - captureFinishedAt).toFixed(1)}, ` + + `total;dur=${(finishedAt - routeStartedAt).toFixed(1)}` + ); + return { terminalBuffer: cleanBuffer, status: session.status, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 879538511..74bb8e5d0 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -722,7 +722,8 @@ export function registerSystemRoutes( const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); - // Apply a changed tmux history-limit to all live sessions immediately. + // tmux 3.7+ resizes tracked panes; older versions apply this to new panes. + // Already-evicted history cannot be recovered on either version. if (settings.tmuxHistoryLimit !== undefined) { await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit); } diff --git a/test/codex-snapshot-replay.test.ts b/test/codex-snapshot-replay.test.ts index 4eade8e95..b9b2a3c29 100644 --- a/test/codex-snapshot-replay.test.ts +++ b/test/codex-snapshot-replay.test.ts @@ -32,7 +32,7 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { const declaration = source.indexOf('let restoredSnapshot = false;', selectStart); const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart); const rewriteDecision = source.indexOf( - 'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer', + 'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer', selectStart ); @@ -58,7 +58,9 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { // Snapshot restore must NOT short-circuit the canonical fetch. expect(snapshotBlock).not.toContain('this._finishBufferLoad();'); expect(postSnapshotRestore).toContain('restoredSnapshot'); - expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); + expect(postSnapshotRestore).toContain( + 'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer' + ); }); it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => { @@ -71,8 +73,8 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { expect(cacheRestore).toBeGreaterThan(-1); expect(busyClear).toBeGreaterThan(cacheRestore); expect(needsRewrite).toBeGreaterThan(busyClear); - expect(replayBlock).toContain('clearedForBusy'); - expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); + expect(replayBlock).toContain('clearedBeforeFresh'); + expect(replayBlock).toContain('restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer'); }); it('loads the SerializeAddon and keeps a per-session snapshot map', () => { diff --git a/test/history-truncation-notice.test.ts b/test/history-truncation-notice.test.ts index 8231e4b83..ef542eefc 100644 --- a/test/history-truncation-notice.test.ts +++ b/test/history-truncation-notice.test.ts @@ -122,6 +122,18 @@ describe('the in-terminal truncation line is gone (static guard)', () => { expect(app).not.toContain('earlier output truncated for performance'); }); + it('loads a bounded shell tail first and keeps full history user-triggered', () => { + const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)"); + expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'"); + expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`'); + expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)'); + expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')"); + expect(app).toContain("if (session?.mode === 'shell')"); + expect(app).toContain("if (!force && session?.mode === 'shell') return;"); + expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'"); + }); + it('renders the banner through textContent, never innerHTML', () => { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); const start = app.indexOf('_renderHistoryTruncationBanner() {'); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b9fe194a1..dbe8efa2d 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -789,6 +789,7 @@ describe('session-routes', () => { expect(body.data.terminalBuffer).toContain(lastLine); expect(body.data.source).toBe('mux-full-history'); expect(typeof body.data.fullSize).toBe('number'); + expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/); }); it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => { diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index 229cebff4..115682c4f 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -89,7 +89,7 @@ describe('terminal flush budget', () => { expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); }); - it('waits for xterm to process small buffer replays before completing buffer load', async () => { + it('releases the live-output gate but waits for xterm to parse a small replay', async () => { const { app, writes } = loadTerminalUiHarness('codex'); let writeDone: (() => void) | undefined; let resolved = false; @@ -109,13 +109,90 @@ describe('terminal flush budget', () => { expect(writes).toEqual(['fresh tmux pane frame']); expect(writeDone).toBeTypeOf('function'); expect(resolved).toBe(false); - expect(finishBufferLoad).not.toHaveBeenCalled(); + expect(finishBufferLoad).toHaveBeenCalledOnce(); writeDone?.(); await promise; expect(resolved).toBe(true); - expect(finishBufferLoad).toHaveBeenCalledOnce(); + }); + + it('paces a large enqueue and releases live output before the parse marker completes', async () => { + const { app, writes } = loadTerminalUiHarness('shell'); + const scheduled: Array<() => void> = []; + let parseDone: (() => void) | undefined; + let resolved = false; + let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined; + app._safeYield = (callback: () => void) => scheduled.push(callback); + app.isTerminalAtBottom = () => true; + app.terminal.buffer = { active: { length: 37 } }; + app.terminal.write = (data: string, callback?: () => void) => { + writes.push(data); + if (callback) parseDone = callback; + }; + + const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => { + result = value; + resolved = true; + }); + expect(writes).toEqual([]); + expect(scheduled).toHaveLength(1); + + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([32 * 1024]); + expect(scheduled).toHaveLength(1); + + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]); + expect(resolved).toBe(false); + + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]); + expect(app._isLoadingBuffer).toBe(false); + expect(resolved).toBe(false); + + app.batchTerminalWrite('new output after snapshot'); + expect(app._loadBufferQueue).toBe(null); + expect(app.pendingWrites).toEqual(['new output after snapshot']); + + parseDone?.(); + app.terminal.buffer.active.length = 42; + await promise; + expect(resolved).toBe(true); + expect(result?.bufferLength).toBe(37); + expect(result?.completed).toBe(true); + }); + + it('marks a parse callback stale when a newer replay supersedes it', async () => { + const { app } = loadTerminalUiHarness('shell'); + let parseDone: (() => void) | undefined; + app.terminal.write = (_data: string, callback?: () => void) => { + parseDone = callback; + }; + + const firstReplay = app.chunkedTerminalWrite('old snapshot'); + app._chunkedWriteGen += 1; + parseDone?.(); + + await expect(firstReplay).resolves.toMatchObject({ completed: false }); + }); + + it('scans xterm rows from buffer.length instead of double-counting baseY', () => { + const { app } = loadTerminalUiHarness('shell'); + const getLine = vi.fn((index: number) => + index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined + ); + app.terminal = { + rows: 24, + buffer: { active: { baseY: 76, length: 100, getLine } }, + scrollToBottom: vi.fn(), + scrollToLine: vi.fn(), + }; + + app.scrollToLastNonEmptyLine(); + + expect(getLine.mock.calls[0]?.[0]).toBe(99); + expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77); }); it('keeps stale buffer load owners from finishing a newer load', () => { diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 7388cfddc..4c87f96e5 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -675,6 +675,7 @@ describe('TmuxManager (unit)', () => { sessionId: 'abc12345-1234-5678-90ab-cdef12345678', workingDir: '/mnt/gdrive/project with spaces', mode: 'shell', + historyLimit: 250_000, }); expect(session.workingDir).toBe('/mnt/gdrive/project with spaces'); @@ -683,7 +684,9 @@ describe('TmuxManager (unit)', () => { const newSessionCall = mockedExecSync.mock.calls.find( ([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ') ); - expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`); + expect(newSessionCall?.[0]).toBe( + `tmux -L 'codeman' set-option -g history-limit 250000 \\; new-session -ds "codeman-abc12345" -c /tmp \\; set-option -t "codeman-abc12345" history-limit 250000` + ); expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' })); const respawnCall = mockedExecSync.mock.calls.find( @@ -696,6 +699,59 @@ describe('TmuxManager (unit)', () => { } }); + it('changes the global history default on tmux versions that cannot resize panes', async () => { + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + await nonTestManager.setHistoryLimit(200_000); + const historyCall = mockedExec.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ') + ); + expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -g history-limit 200000`); + expect(historyCall?.[0]).not.toContain(' -t '); + } finally { + nonTestManager.destroy(); + } + }); + + it('targets only the new and tracked sessions on tmux 3.7+', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.endsWith(' -V')) return 'tmux 3.7b\n'; + if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n'; + if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n'; + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + await nonTestManager.createSession({ + sessionId: 'def67890-1234-5678-90ab-cdef12345678', + workingDir: '/project', + mode: 'shell', + historyLimit: 250_000, + }); + const newSessionCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ') + ); + expect(newSessionCall?.[0]).toBe( + `tmux -L 'codeman' new-session -ds "codeman-def67890" -c /tmp \\; set-option -t "codeman-def67890" history-limit 250000` + ); + expect(newSessionCall?.[0]).not.toContain('set-option -g'); + + mockedExec.mockClear(); + await nonTestManager.setHistoryLimit(200_000); + const historyCall = mockedExec.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ') + ); + expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -t 'codeman-def67890' history-limit 200000`); + expect(historyCall?.[0]).not.toContain('set-option -g'); + } finally { + nonTestManager.destroy(); + } + }); + it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => { const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); const nonTestManager = new NonTestTmuxManager();