export declare const FRAME_STDIN = 1; export declare const FRAME_STDOUT = 2; export declare const FRAME_RESIZE = 3; export declare const FRAME_HELLO = 4; export declare const FRAME_GOODBYE = 5; export declare const FRAME_RTT = 6; export declare const FRAME_REPLACED = 7; /** Host → relay: round-trip your terminal now and say what it answered. */ export declare const FRAME_PROBE = 8; /** Relay → Host: the answer to {@link FRAME_PROBE}. */ export declare const FRAME_PROBE_REPLY = 9; /** A prober → Host: is a window *really* on this session? Does not claim it. */ export declare const FRAME_QUERY = 10; /** Host → prober: the answer to {@link FRAME_QUERY}. */ export declare const FRAME_QUERY_REPLY = 11; /** * Relay → host: what this terminal does with the ambiguous glyphs. * * Payload: one byte, bit 0 = the terminal advances two cells, bit 1 = the second * cell has to be reserved by us with a space. Sent right after HELLO, because the * Host decides every width in the session and it is the relay — not the Host — * that owns the terminal to measure on. */ export declare const FRAME_METRICS = 12; /** * What a relay's own terminal round trip said. * * `live` — the terminal answered just now, so somebody has a screen in front of * them. `silent` — it answered when this relay attached and does not answer * now: that window is gone. The distinction is the whole point: a cut SSH link * leaves the launcher *connected* (it sees no hangup until sshd does, which * can be hours with TCP keepalive), so the socket alone cannot tell a live * window from a dead one — only the far end can. `unknown` — it never answered * (a pipe, a dumb terminal, or a link that was already dead at attach), and * silence must not be read as an answer. */ export type TerminalVerdict = 'live' | 'silent' | 'unknown'; /** What a Host tells a prober about the window on its session. */ export type AttachmentVerdict = 'attached' | 'detached' | 'unknown'; /** * Drop launcher SIGTERM/SIGINT/SIGHUP so closing SSH cannot dispose the tree * before hangup handling. Leaving the session with setsid() is best-effort: * a TTY session leader gets EPERM and stays in the SSH process group. */ export declare function detachFromSshSession(): void; export declare const TUI_HOST_ENV = "DSH_TUI_HOST"; export declare function isTuiHostProcess(env?: NodeJS.ProcessEnv): boolean; /** * Windows named-pipe namespace. Node's `net` passes the string straight to * CreateNamedPipeW/CreateFileW, and those require this prefix — a drive-letter * path fails with ENOENT/EACCES and the Host never listens. */ export declare const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\"; /** True for a Windows named-pipe address (`\\.\pipe\x`, `\\?\pipe\x`, `//./pipe/x`). */ export declare function isPipePath(path: string): boolean; /** * `DSH_HOME` the way dsh itself resolves it: a blank value counts as unset, and * the result is absolute. Both matter here because the launcher and the * detached Host compute channel names independently and the Host chdirs into * the session's working directory first — a relative or empty home would give * them two different sockets (or pipe names) and time the launch out. */ export declare function resolveDshHome(env?: NodeJS.ProcessEnv, home?: string): string; /** Directory holding per-session display runtime state (error logs). */ export declare function sessionSockDir(dshHome?: string): string; /** Filesystem/pipe-safe form of a session id, as used for locks and channels. */ export declare function safeSessionId(sessionId: string): string; /** * Readable, collision-free short label for one session: a sanitized head plus a * digest of the raw id. The digest is always appended because sanitizing alone * collapses distinct ids (`foo/bar` and `foo_bar`, `abc` and `abc.`) into one * name. On Windows the pipe namespace is machine-wide; on POSIX the same * collapse would share a socket file and a lock, so a relay could attach to * the wrong session. */ export declare function sessionLabel(sessionId: string, maxLength: number): string; /** * Address of the per-session display channel: a filesystem path on POSIX, a * named pipe on Windows. `platform` is injectable so the Windows shape stays * testable from a POSIX test run. */ export declare function sessionSockPath(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string; /** * Pre-digest POSIX socket path (`tui-socks/.sock`). * * 0.7.1 Hosts still listen here. A newer build's attach must find that channel * instead of spawning a second Host that dies on the session write handle. * Distinct ids that collapsed under sanitizing (`foo/bar` vs `foo_bar`) * share this name — that is why the digest form exists — so a leftover * file is only an attach target, never the path a new Host binds. */ export declare function legacySessionSockPath(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string | undefined; /** * Channel a leftover Host may still be listening on: the digested path first, * then the 0.7.1 name when it is different. */ export declare function sessionSockLookupPaths(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string[]; /** * Host stderr log for one session. POSIX keeps the historical `.err` * next to the socket; a Windows pipe name is not a file path, so the log lives * in the `tui-socks` state directory instead — under the same digested label, * which also keeps long ids inside the Windows path limit and keeps reserved * device names (`CON`, `NUL`, …) from becoming the file stem. */ export declare function sessionErrPath(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string; /** * Where the hidden-console bootstrap writes the Host's pid. * * On `\\.\pipe\` Windows there is no socket file to derive a name from, and the * state directory already holds the per-session lock and stderr log, so the pid * file lives beside them. It is removed as soon as it has been read. */ export declare function sessionBootstrapPidPath(sessionId: string, dshHome?: string): string; /** Pre-digest Host stderr log next to the 0.7.1 socket, when that name differs. */ export declare function legacySessionErrPath(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string | undefined; export declare function encodeFrame(type: number, payload?: Buffer): Buffer; /** * The size a relay should report. * * A terminal knows its own size; a parent that speaks the protocol on pipes is * not a terminal, so it declares one through `COLUMNS`/`LINES` — the convention * every shell and TUI already shares — and corrects it later with a resize frame * if the window changes. Without either, the historical 80×24 stands. * @param stdout - the stream that may be a terminal. * @param env - the environment to read. * @returns the columns and rows to send as the first resize. */ export declare function relayTerminalSize(stdout?: { columns?: number; rows?: number; }, env?: NodeJS.ProcessEnv): { columns: number; rows: number; }; export declare function encodeResize(columns: number, rows: number): Buffer; export declare function decodeResize(payload: Buffer): { columns: number; rows: number; } | undefined; export declare function encodeRtt(rttMs: number | undefined): Buffer; export declare function decodeRtt(payload: Buffer): number | undefined; export declare function encodeProbe(): Buffer; export declare function encodeProbeReply(verdict: TerminalVerdict): Buffer; export declare function decodeProbeReply(payload: Buffer): TerminalVerdict; /** Encode {@link FRAME_METRICS}. */ export declare function encodeMetrics(metrics: { wide: boolean; reserve: boolean; }): Buffer; /** Decode {@link FRAME_METRICS}; undefined when the payload is not one byte. */ export declare function decodeMetrics(payload: Buffer): { wide: boolean; reserve: boolean; } | undefined; export declare function encodeQuery(): Buffer; export declare function encodeQueryReply(verdict: AttachmentVerdict): Buffer; export declare function decodeQueryReply(payload: Buffer): AttachmentVerdict; /** Incremental decoder for one socket. */ export declare class FrameReader { private buffer; push(chunk: Buffer): Array<{ type: number; payload: Buffer; }>; } export interface DisplayHostHandlers { onStdin(bytes: Buffer): void; onResize(columns: number, rows: number): void; onRtt?(rttMs: number | undefined): void; /** The relay measured this terminal's ambiguous glyphs; adopt the verdict. */ onMetrics?(metrics: { wide: boolean; reserve: boolean; }): void; onDetach(info?: { replaced?: boolean; }): void; onAttach(): void; } /** * Host-side listener. At most one Display is attached; a new hello kicks the * previous relay so two SSH windows cannot both drive the session. */ export declare class DisplayHost { readonly path: string; private readonly handlers; /** Test seams: how long a silent connection may wait for its HELLO, and * how long a relay may take to report its terminal round trip. */ private readonly options; private server; private socket; private reader; attached: boolean; /** Set while a {@link FRAME_PROBE} round trip is waiting for its answer. */ private probeSettle; private probeInFlight; constructor(path: string, handlers: DisplayHostHandlers, /** Test seams: how long a silent connection may wait for its HELLO, and * how long a relay may take to report its terminal round trip. */ options?: { helloGraceMs?: number; probeTimeoutMs?: number; }); listen(): Promise; private accept; /** * Whether a window is really on this session — asked of the window itself. * * "The socket answers" is not evidence. A relay whose SSH link was cut stays * connected (its launcher sees no hangup until sshd does), and the Host then * truthfully reports a display that no longer has a screen behind it. Only the * far end can settle it, so this round-trips the terminal through the relay. */ attachmentVerdict(): Promise; /** One answer to one prober; never claims the display, never throws. */ private answerQuery; /** * Ask the claimed relay to round-trip its terminal, with a deadline. * * Sharing one in-flight round trip matters: a second question while the first * is unanswered would be settled by the first reply, and two windows asking at * once is a normal resume, not an error. */ private probeTerminal; private settleProbe; /** * How many bytes are queued for the terminal and still undelivered. * * The Host composes every frame, and on a resize it is the only one that can * decide whether there is room for another one: the socket the frames travel on * belongs to this object. Without the number the Host can only *guess* how fast * the link is (from the measured round trip) and pace itself conservatively, * which is what made a drag lag behind the pointer on a fast link. With it, the * rule becomes the honest one: paint the new size now, unless the previous frame * has not left yet. * @returns queued bytes, or undefined when no display is attached. */ pendingBytes(): number | undefined; sendStdout(bytes: Buffer | string): boolean; sendGoodbye(): void; /** * Say goodbye and let the frame leave before the socket is reaped. * * The frame is what tells the launcher to exit instead of reading the close as * a crash and re-attaching (`attach.ts`) — the replacement path flushes * `FRAME_REPLACED` for exactly the same reason. A write only *queues* on libuv * until the peer drains it, and reaping the socket cancels what is still * queued: on a busy link, with a starting session still painting into that * socket, the goodbye was dropped along with those frames and the launcher * re-attached over the user's own `/exit` instead of exiting. */ private farewellAndReap; close(): Promise; } /** * True once a Host is actually accepting on the display channel. * * Always a real connect, never a filesystem check: a leftover `.sock` file * (killed Host, or one that is mid-dispose) is a directory entry, not a peer, * and `fs.access()` used to report it as ready — the launcher then wrote HELLO * into a dead socket and the first reconnect after a drop died with * `write EPIPE`. Windows pipes additionally cannot be seen by `fs.access` at * all. The Host treats a connect without HELLO as a liveness probe and drops * it without stealing the display. */ export declare function displaySockExists(path: string, timeoutMs?: number): Promise; /** * How long a prober waits for the Host's answer to {@link FRAME_QUERY}. * * It has to cover the relay's own two probe windows (see * {@link DISPLAY_PROBE_TIMEOUT_MS}) plus the hop; the answer only takes this * long when the display really is silent. */ export declare const DISPLAY_QUERY_TIMEOUT_MS = 4000; /** * Ask a live Host whether a window is really on its session. * * `undefined` means the Host did not answer — an older Host that does not know * the frame, or one whose event loop is stuck. Every caller must read that as * "cannot tell" and never as "free": the cost of the doubt is one confirmation * prompt, and the cost of guessing wrong is taking a session away from a window * somebody is still typing in. */ export declare function queryDisplayAttachment(path: string, timeoutMs?: number): Promise; /** Watches a freshly spawned Host so a crash is reported immediately. */ export interface HostExitWatch { /** Resolves with the exit code (or null when killed) once the Host exits. */ readonly exited: Promise; /** Stop watching; call once the channel is confirmed up. */ dispose(): void; } /** Exported for the test that pins its loop-ref behaviour; not public API. */ export declare function watchHostPid(pid: number): HostExitWatch; /** * How long a dead-pid report waits for the child's `exit` event before giving * up on its code. The event is normally delivered within a tick; the bound only * exists so a host that never reports still fails fast. */ export declare const HOST_EXIT_GRACE_MS = 250; export declare function waitForDisplaySock(path: string, timeoutMs?: number, pid?: number, errFile?: string, exitWatch?: HostExitWatch): Promise; export declare function hostArgvForSession(sessionId: string, argv?: string[], execArgv?: string[]): string[]; export interface SpawnedHost { pid: number; /** Channel address: a socket file on POSIX, a named pipe on Windows. */ sock: string; /** Host stderr log; present when it could be opened. */ errFile?: string; /** Exit watch so a Host that dies before listening is reported at once. */ exitWatch: HostExitWatch; } /** * Keep what the user types while no display exists yet. * * A fresh Host takes a moment to boot; the launcher used to leave stdin flowing * with nobody listening, so every key pressed in that window was dropped. The * capture strips cursor replies (it is the same stream the relay will probe) * and hands the rest to the relay as its `seed`. */ export declare function captureTerminalInput(stdin?: NodeJS.ReadStream): { stop(): string; }; /** * Keep the terminal quiet while the launcher retries or waits for a Host. * * Between attempts the TTY is back in cooked mode, so a DSR reply still in * flight from the previous probe is echoed to the screen as `^[[17;1R`-style * garbage. Raw mode plus a drain swallows those bytes instead: they are either * stale replies or keys typed before any display existed, and neither should * reach the shell. */ export declare function quietTerminalInput(stdin?: NodeJS.ReadStream): number; /** * Hand the TTY back to the shell: cooked mode, echo back, input paused. * * `quietTerminalInput` deliberately leaves raw mode on while a relay is about * to take the terminal, and the relay restores it on its way out — but every * exit path then calls `quiet()` once more to drain a cursor reply still owed, * which turns raw mode back on. Without this the shell comes back with no line * discipline and no echo: the user cannot type, and the only recovery is * dropping the SSH connection. Only local termios calls here — nothing is * written into a link that may already be dead. */ export declare function restoreTerminalInput(stdin?: NodeJS.ReadStream): void; /** * The environment a detached Host is started with. * * Notable entries: the marker that tells the child it *is* the Host, and the * ambiguous-width decision. The Host paints, but its own stdout is the relay * socket, so it cannot look at a TTY to know how wide the window's terminal * draws `①` or `—`; this process has that terminal, so it decides and hands the * answer over. An explicit value in the environment still wins. * @param env - the parent environment (a test passes its own). * @param onTerminal - whether *this* process owns a terminal; it is the evidence * the width table is chosen from, and a test harness is not a terminal. * @returns the child environment. */ export declare function hostChildEnv(env?: NodeJS.ProcessEnv, onTerminal?: boolean, measuredAmbiguousWide?: boolean): NodeJS.ProcessEnv; /** Test seam for {@link spawnDetachedHost}; production passes nothing. */ export interface SpawnHostOptions { /** * What the launcher measured for the ambiguous glyphs, when it measured. * * The Host paints every frame with a socket for stdout, so it cannot ask the * terminal anything; the answer travels in its environment instead. */ ambiguousWide?: boolean; /** * Start the Host through this command instead of resolving the real one, or * `null` to spawn it directly even where a bootstrap exists. * * `null` is for the tests that are about the *direct* path — the fixture Hosts * in `tests/display-host-e2e.test.mjs` assert on the child's exit code, which * only a real child handle can report (a pid watch resolves `null`). The * bootstrap has its own coverage: the builders on every platform, and the real * Windows probes end to end. */ bootstrap?: { command: string; args: string[]; } | null | undefined; /** How long the bootstrap may take to report a pid (Windows PowerShell start). */ bootstrapTimeoutMs?: number; } /** * Start the Host through the hidden-console bootstrap and return its pid, or * `undefined` when the bootstrap could not report one. * * `spawnSync` on purpose. The pid has to be in hand before this function * returns (the caller watches it, and the fallback must never leave two Hosts * for one session), and the cost is one bounded wait while the boot splash is * already on screen. PowerShell exits as soon as `Start-Process` has created the * Host, so the wait is its own start-up, not the Host's. * * Falling back is safe exactly when nothing was printed: `Start-Process -PassThru` * either starts the Host and prints its id, or throws before starting anything * (`$ErrorActionPreference = 'Stop'`). A *timeout* is the one case where a Host * might exist and the pid was lost, so it does not fall back — it reports. */ export declare function spawnHostThroughBootstrap(bootstrap: { command: string; args: string[]; }, options: { env: NodeJS.ProcessEnv; platform: NodeJS.Platform; timeoutMs: number; /** File the bootstrap writes the Host's pid to. */ pidFile: string; }): { pid: number; } | undefined; /** * Spawn a detached Host copy of this `dsh` invocation and return its sock path. * * On Windows the Host goes through {@link hostBootstrapCommand} when the OS * PowerShell is available: a direct spawn there cannot both survive the * launcher (libuv's `KILL_ON_JOB_CLOSE` job takes a non-detached child with it) * and avoid flashing console windows (`detached` is DETACHED_PROCESS, which makes * Windows ignore `CREATE_NO_WINDOW`). The bootstrap gives the Host a console of * its own, hidden — see `docs/platform.md`. Without it, the direct spawn below * is still what runs, with the old semantics. */ export declare function spawnDetachedHost(sessionId: string, platform?: NodeJS.Platform, options?: SpawnHostOptions): SpawnedHost; export declare function probeDisplaySock(path: string, timeoutMs?: number): Promise; export interface RelayResult { /** Host sent goodbye — user exited from the attached session. */ reason: 'goodbye' | 'host-closed' | 'signal' | 'replaced'; } /** Terminal and signal sources; injectable so a test can drive the relay. */ export interface DisplayRelayOptions { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream; signals?: Pick; /** Link kind for the RTT probe; defaults to this process's SSH env. */ ssh?: boolean; /** Re-measure cadences (tests); defaults to {@link RTT_RECHECK_FIRST_MS} / {@link RTT_RECHECK_MS}. */ rttFirstRecheckMs?: number; rttRecheckMs?: number; /** Terminal-size poll cadence (tests); default {@link SIZE_RECHECK_MS}. */ sizeRecheckMs?: number; /** Typing captured before this relay existed; sent to the Host after HELLO. */ seed?: string; /** * The waiting status line currently on screen, if the launcher announced one. * It is erased here — after the measurement, before the Host's first paint — * so the picker's screen does not sit frozen while the probe runs. */ announce?: boolean; } /** * Turn this process into a Display relay until the Host hangs up or the * local TTY dies. Restores the terminal before resolving. */ export declare function runDisplayRelay(path: string, options?: DisplayRelayOptions): Promise;