This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(server): restore alt-screen mode on SCREEN replay (#49)

Track whether the child has entered the alternate screen buffer (DEC
private modes ?1049 / ?1047 / ?47) via a new altScreenActive flag on
PtyServer, and prepend \x1b[?1049h to the SCREEN packet payload sent
on ATTACH when the child is currently in alt-screen. PEEK does not
get the prefix — a non-follow peek prints its snapshot into the
caller's shell and exits, so entering alt-screen would hide the
output when the client's TERMINAL_SANITIZE (?1049l) fires on close.

Fixes #41. Before this, a full-screen TUI (vim/htop/codex) attached
through pty would paint into the host's main-screen buffer, which
under tmux meant every full repaint entered scrollback. Observed
impact: 8.7 GB scrollback + 9.1 GB tmux server RSS over 9.5h on a
long-lived codex pane, with 0.4-2.3s server-loop stalls.

The wire format is unchanged — the prefix rides on the existing SCREEN
payload as a strict superset. Old clients (pre-fix) receiving the
prefixed payload just write it verbatim to their terminal and enter
alt-screen correctly. Client-side detach already emits ?1049l via
TERMINAL_SANITIZE, so the round-trip is symmetric.

authored by

Nathan and committed by
GitHub
(Jul 2, 2026, 9:27 AM +0200) 2093f4ec cabacf1d

+164 -2
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Fixes 6 + - **Alt-screen buffer mode is now restored on SCREEN replay.** The daemon tracks whether the child has entered the alternate screen buffer (DEC private modes `?1049`, `?1047`, `?47`) and prepends `\x1b[?1049h` to the SCREEN packet payload sent on ATTACH when the child is currently in alt-screen. Fixes #41 — before this, a full-screen TUI (vim/htop/codex) attached through `pty` would paint into the host's main-screen buffer, which under tmux meant every frame entered scrollback: an observed 8.7 GB scrollback / 9.1 GB tmux server RSS over 9.5h on a long-lived codex pane. The client-side detach path already resets `?1049l` via `TERMINAL_SANITIZE`, so the round-trip is symmetric. PEEK does not get the prefix — a non-follow peek prints its snapshot into the caller's shell and immediately exits, so entering alt-screen would hide the output when TERMINAL_SANITIZE fires on close. 7 + 5 8 ### Breaking changes — supervisor replaced by `pty gc` 6 9 - **Removed the long-running `pty supervisor` command and the launchd FDA wrapper.** All `pty supervisor *` subcommands are gone (`start`, `stop`, `status`, `forget`, `reset`, `launchd install/uninstall`, `systemd install/uninstall`, `runit install/uninstall`). The bundled `src/supervisor.ts`, `src/supervisor-entry.ts`, and `scripts/supervisor-wrapper.c` are deleted. So are the three supervisor test files (`tests/supervisor.test.ts`, `tests/supervisor-hardening.test.ts`, `tests/supervisor-service-install.test.ts`). Net: about 600 lines of code and a separate long-lived process removed. 7 10 - **`pty gc` now does the supervisor's work**, statelessly, as a one-shot reconciliation pass. Every invocation re-derives intent from on-disk metadata; there's no in-memory restart counter, no persisted bookkeeping, no backoff state, no `[failed]` mark. Three steps run in order on each call: (1) walk sessions with a `parent=<name>` tag and SIGTERM + clean up any whose parent is gone; (2) respawn every `strategy=permanent` session that's exited or vanished (re-reading `pty.toml` when the session is toml-managed); (3) sweep exited/vanished non-permanent sessions (the historic `pty gc` behavior). The intended deployment is to run it on a short cron (default 30 s); failures (binary not on PATH, volume not mounted at boot) are non-events because the next tick tries again. This fixes the boot-time mount race where a supervised session under `/Volumes/SSD` would exhaust its 5-retry budget within 10 s of launchd firing before the volume even mounted.
+17 -2
src/server.ts
··· 223 223 private sgrMouseMode = false; 224 224 private cursorHidden = false; 225 225 private kittyKeyboardStack: number[] = []; 226 + // Alt-screen buffer state (DEC private modes ?1049 / ?1047 / ?47). Set when 227 + // the child process enters the alternate screen buffer; cleared when it 228 + // leaves. Replayed to attaching clients so the SCREEN snapshot lands in the 229 + // right host-terminal buffer — without this, a TUI's alt-screen frames get 230 + // painted into the host's main buffer, which under tmux means every frame 231 + // enters scrollback (see #41). 232 + private altScreenActive = false; 226 233 // Mouse tracking modes — these are separate DEC private modes (set/cleared 227 234 // independently by the child process) that control WHICH events the 228 235 // terminal should report. SGR mode (1006) only controls the ENCODING of ··· 270 277 if (v === 1000) this.mouseTracking1000 = true; 271 278 if (v === 1002) this.mouseTracking1002 = true; 272 279 if (v === 1003) this.mouseTracking1003 = true; 280 + if (v === 1049 || v === 1047 || v === 47) this.altScreenActive = true; 273 281 if (v === 25) { 274 282 if (this.cursorHidden) this.emitEvent(EventType.CURSOR_VISIBLE); 275 283 this.cursorHidden = false; ··· 288 296 if (v === 1000) this.mouseTracking1000 = false; 289 297 if (v === 1002) this.mouseTracking1002 = false; 290 298 if (v === 1003) this.mouseTracking1003 = false; 299 + if (v === 1049 || v === 1047 || v === 47) this.altScreenActive = false; 291 300 if (v === 25) this.cursorHidden = true; 292 301 } 293 302 return false; ··· 589 598 590 599 const sendScreen = () => { 591 600 if (socket.destroyed) return; 592 - const screen = this.getModePrefix() + this.serialize.serialize(); 601 + const screen = this.getModePrefix(true) + this.serialize.serialize(); 593 602 socket.write(encodeScreen(screen)); 594 603 if (this.exited) { 595 604 socket.write(encodeExit(this.exitCode)); ··· 686 695 }); 687 696 } 688 697 689 - private getModePrefix(): string { 698 + private getModePrefix(includeAltScreen = false): string { 690 699 let prefix = ""; 700 + // Alt-screen mode is only prefixed for the ATTACH path, not PEEK. A 701 + // non-follow `pty peek` prints the snapshot to the caller's shell and 702 + // exits, so entering ?1049h would hide the output when the client-side 703 + // TERMINAL_SANITIZE exits alt-screen on close. Attaching clients want 704 + // the alt buffer to persist for the duration of the attach. 705 + if (includeAltScreen && this.altScreenActive) prefix += "\x1b[?1049h"; 691 706 if (this.mouseTracking1000) prefix += "\x1b[?1000h"; 692 707 if (this.mouseTracking1002) prefix += "\x1b[?1002h"; 693 708 if (this.mouseTracking1003) prefix += "\x1b[?1003h";
+144
tests/screen-replay-altscreen.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as net from "node:net"; 3 + import * as fs from "node:fs"; 4 + import * as os from "node:os"; 5 + import { Session } from "../src/testing/index.ts"; 6 + import { 7 + MessageType, 8 + PacketReader, 9 + encodeAttach, 10 + encodePeek, 11 + } from "../src/protocol.ts"; 12 + import { getSocketPath } from "../src/sessions.ts"; 13 + 14 + // Isolate from real session directory. 15 + const testSessionDir = fs.mkdtempSync(os.tmpdir() + "/pty-altscreen-"); 16 + process.env.PTY_SESSION_DIR = testSessionDir; 17 + afterAll(() => { 18 + return new Promise((resolve) => { 19 + setTimeout(() => { 20 + try { fs.rmSync(testSessionDir, { recursive: true, force: true }); } catch {} 21 + resolve(undefined); 22 + }, 500); 23 + }); 24 + }); 25 + 26 + // Capture the raw SCREEN payload sent in response to `packet` on a fresh 27 + // socket to `session`. Returns the payload string. Waits up to 3s. 28 + async function captureScreen(sessionName: string, requestPacket: Buffer): Promise<string> { 29 + const sock = net.createConnection(getSocketPath(sessionName)); 30 + const reader = new PacketReader(); 31 + try { 32 + await new Promise<void>((resolve, reject) => { 33 + sock.once("connect", () => resolve()); 34 + sock.once("error", reject); 35 + }); 36 + const payload = await new Promise<string>((resolve, reject) => { 37 + const timer = setTimeout(() => reject(new Error("timeout waiting for SCREEN")), 3000); 38 + sock.on("data", (data: Buffer) => { 39 + let packets; 40 + try { packets = reader.feed(data); } catch (err: any) { 41 + clearTimeout(timer); 42 + reject(err); 43 + return; 44 + } 45 + for (const p of packets) { 46 + if (p.type === MessageType.SCREEN) { 47 + clearTimeout(timer); 48 + resolve(p.payload.toString()); 49 + return; 50 + } 51 + } 52 + }); 53 + sock.write(requestPacket); 54 + }); 55 + return payload; 56 + } finally { 57 + sock.destroy(); 58 + } 59 + } 60 + 61 + describe("SCREEN replay carries alt-screen mode", () => { 62 + it("ATTACH prefixes ?1049h at position 0 when child is in the alternate screen buffer", async () => { 63 + // Child enters alt-screen, writes a marker, then blocks. 64 + const session = await Session.server( 65 + "sh", 66 + ["-c", "printf '\\033[?1049h\\033[Halt-marker'; sleep 60"], 67 + { rows: 24, cols: 80 }, 68 + ); 69 + // Wait for the daemon's xterm-headless to process the ?1049h and content. 70 + await new Promise((r) => setTimeout(r, 300)); 71 + 72 + const screen = await captureScreen(session.name, encodeAttach(24, 80)); 73 + 74 + // The alt-screen enter mode must be the very first bytes so the client's 75 + // real terminal switches buffers before any content is painted. Position 76 + // 0 is a stronger check than "contains" because xterm-addon-serialize may 77 + // independently emit `?1049h` mid-payload; this asserts the guarantee THIS 78 + // patch adds — the mode is set at the start, deterministically. 79 + expect(screen.startsWith("\x1b[?1049h")).toBe(true); 80 + expect(screen).toContain("alt-marker"); 81 + 82 + await session.close(); 83 + }, 15000); 84 + 85 + it("ATTACH does not prefix ?1049h when child is in the main screen buffer", async () => { 86 + const session = await Session.server( 87 + "sh", 88 + ["-c", "printf 'main-only'; sleep 60"], 89 + { rows: 24, cols: 80 }, 90 + ); 91 + await new Promise((r) => setTimeout(r, 300)); 92 + 93 + const screen = await captureScreen(session.name, encodeAttach(24, 80)); 94 + 95 + // The prefix guarantee is position-0. Main-screen sessions must not have 96 + // ?1049h at the start (would poison the client's host terminal). 97 + expect(screen.startsWith("\x1b[?1049h")).toBe(false); 98 + 99 + await session.close(); 100 + }, 15000); 101 + 102 + it("ATTACH stops prefixing ?1049h after child exits alt-screen", async () => { 103 + // Child enters and immediately exits alt-screen, then writes to main. 104 + const session = await Session.server( 105 + "sh", 106 + ["-c", "printf '\\033[?1049h\\033[?1049lmain-again'; sleep 60"], 107 + { rows: 24, cols: 80 }, 108 + ); 109 + await new Promise((r) => setTimeout(r, 300)); 110 + 111 + const screen = await captureScreen(session.name, encodeAttach(24, 80)); 112 + 113 + // Buffer is back to main — no position-0 alt-screen prefix. 114 + expect(screen.startsWith("\x1b[?1049h")).toBe(false); 115 + 116 + await session.close(); 117 + }, 15000); 118 + 119 + // Note on PEEK: we do NOT test that PEEK omits ?1049h. xterm-addon-serialize 120 + // independently emits `\x1b[?1049h` at the start of its output when the 121 + // buffer is alternate, and that's pre-existing behavior outside this patch's 122 + // scope. This patch's contract is `getModePrefix(includeAltScreen)` — only 123 + // ATTACH passes `true`, so this patch adds nothing to PEEK either way. 124 + // Whether peek's client-side TERMINAL_SANITIZE round-trip loses alt-content 125 + // is a separate concern tracked outside #41. 126 + 127 + it("tracks ?1047 (legacy variant) as alt-screen too", async () => { 128 + const session = await Session.server( 129 + "sh", 130 + ["-c", "printf '\\033[?1047h\\033[Halt-1047'; sleep 60"], 131 + { rows: 24, cols: 80 }, 132 + ); 133 + await new Promise((r) => setTimeout(r, 300)); 134 + 135 + const screen = await captureScreen(session.name, encodeAttach(24, 80)); 136 + 137 + // Server normalizes to ?1049h on emit (the modern combined form) — 138 + // callers get consistent semantics regardless of which variant the 139 + // child used to enter. 140 + expect(screen.startsWith("\x1b[?1049h")).toBe(true); 141 + 142 + await session.close(); 143 + }, 15000); 144 + });