import type { SessionBackend, SpawnOpts } from './types.js'; /** Test seam: the startup retries sleep SYNCHRONOUSLY (Atomics.wait ignores * fake timers), which would add real seconds to the unit suite. Pass null to * restore the real sleep. */ export declare function setStartupTmuxRetrySleepForTests(fn: ((ms: number) => void) | null): void; /** Convert `\n` to `\r\n` while leaving existing `\r\n` alone. Exported for * unit tests; used to normalise tmux capture-pane output before sending it * to xterm.js (which treats bare LF as "down one row, keep column"). */ export declare function normaliseCaptureLineEndings(s: string): string; /** * Compose the web-terminal seed body from a normalised capture-pane snapshot * and the pane's cursor position. * * The receiving xterm replays this body and then resumes the LIVE pipe-pane * byte stream. Claude Code (and other Ink TUIs) repaint their bottom block with * height-RELATIVE moves (`\x1b[A` + `\r\n`), so the FIRST live redraw assumes * the cursor sits exactly where the pane's cursor is. Raw capture-pane output * carries no cursor position and ends with a trailing newline — that newline * scrolls the receiving grid one row PAST the content (desyncing the viewport * from the app's coordinates) and parks the cursor on the bottom row instead of * the app's real row. The first relative redraw then lands a row low, and * because the CLI tracks position relatively, every subsequent frame stays * shifted (the status-line update bleeds into the line below — the bug 示例用户 * reported). * * Fix: strip the SINGLE trailing line terminator so no extra scroll happens, * then restore the cursor with CUP (`\x1b[row;colH`). Strip exactly one `\r\n`, * NOT a greedy `(\r\n)+` — capture-pane emits every pane row including trailing * BLANK rows below the cursor (Claude's bottom row is usually blank). Greedily * stripping would delete those blank rows and shift the whole grid up one row, * parking the cursor above the real input line (an upward drift — the same bug, * mirrored). CUP is viewport-relative and tmux's `cursor_x`/`cursor_y` are * 0-based viewport coordinates, so +1 each lands correctly even when the capture * includes full scrollback. Verified against a real 208x62 Claude pane. * Exported for tests. */ export declare function composeSeedBody(normalisedCapture: string, cursor: { x: number; y: number; } | null): string; /** Pane input modes tmux tracks per pane but capture-pane cannot express. * A capture-pane seed carries screen cells only — the DECSET state that tells * the receiving xterm to REPORT mouse events (or use app cursor keys) is * gone, so a mouse-mode TUI (grok build: 1003+1006) never hears clicks from * a freshly-connected web client. */ export interface PaneInputModes { mouseStandard: boolean; mouseButton: boolean; mouseAll: boolean; mouseSgr: boolean; appCursorKeys: boolean; appKeypad: boolean; cursorVisible: boolean; } /** Render {@link PaneInputModes} as the escape sequence that re-asserts them on * a fresh xterm. Appended AFTER the seed body: DECSET never moves the cursor, * so composeSeedBody's cursor restore stays intact. Exported for tests. */ export declare function paneInputModeSeed(m: PaneInputModes): string; /** tmux display-message format string matching {@link parsePaneModeFlags}. */ export declare const PANE_MODE_FLAGS_FORMAT: string; /** Parse the {@link PANE_MODE_FLAGS_FORMAT} output. Null on malformed output * (pane vanished mid-query → tmux prints an error line, not flags). */ export declare function parsePaneModeFlags(out: string): PaneInputModes | null; /** * Spread lifecycle probes after a mass daemon restore. Workers are separate * processes, so a process-local mutex cannot prevent them all from hitting the * same default tmux server on the same millisecond. A stable target-derived * offset keeps probes distributed without introducing test/runtime randomness. */ export declare function tmuxLifecycleInitialDelayMs(target: string): number; export declare class TmuxPipeBackend implements SessionBackend { readonly supportsRawCommandPasteLine = true; /** Real tmux pane address (e.g. "0:2.0") or botmux session name (bmx-*). */ private readonly paneTarget; private readonly fifoPath; private readStream; /** Streaming UTF-8 decoder. The fifo read emits raw Buffer chunks at libuv's * 64KB highWaterMark boundary, which can fall in the middle of a multi-byte * character (CJK = 3 bytes, box-drawing = 3 bytes, emoji = 4 bytes). Decoding * each chunk independently with `chunk.toString('utf8')` would split that * character into U+FFFD replacement chars on both halves — one wide glyph * becomes 2-3 garbage chars and every following column shifts right, which * is the intermittent "错位" seen in the web terminal during heavy CLI * re-renders (a full redraw is a big burst, far more likely to cross a 64KB * boundary). StringDecoder holds the incomplete trailing bytes and prepends * them to the next chunk, so a character split across reads is reassembled. */ private readonly decoder; private readonly dataCbs; /** Bounded tail of the decoded output tmux most recently replicated from the * pane (kept to the last RECENT_OUTPUT_MAX UTF-16 code units, not an exact * byte count — fine for a diagnostic). Crash diagnostic: when a send fails * because the pane vanished, capture-pane can no longer read the (now-gone) * screen — but this text was already received over the pipe and is the * CLI's actual final stdout/stderr (e.g. a gateway/API error) right before * it exited. */ private recentOutput; private static readonly RECENT_OUTPUT_MAX; private readonly exitCbs; private lifecycleTimer; private lastUnknownProbeLogAt; /** Debounce authoritative pane-missing replies. Probe timeouts / EMFILE / * spawn failures are classified as unknown and never enter this destructive * counter. (Adopted CLI pid-death stays decisive.) */ private readonly livenessGate; /** Consecutive lifecycle probes that did NOT answer 'exists' (missing OR * unknown). Drives probe-interval backoff only — never a teardown decision: * during a shared-server outage hundreds of workers re-probing every second * are themselves part of the connect-storm that keeps the accept backlog * overflowing. */ private probeSetbackStreak; /** Wall-clock start of an unbroken run of CONNECTION-level probe failures * (see probePaneAddressability serverLevel). Connection failures are * 'unknown' and never trip the liveness gate — but a genuinely dead server * also produces them forever, and its panes ARE dead. Escalate to pane-exit * only after the server has been continuously unreachable for * SERVER_OUTAGE_ESCALATE_MS. Cleared by any server-answered probe; probe * timeouts leave it untouched (a wedged-but-alive server neither proves nor * disproves the outage). */ private serverUnreachableSince; private static readonly SERVER_OUTAGE_ESCALATE_MS; private cols; private rows; private exited; /** Set after pipe-pane subscription is active so kill() knows to cancel it. */ private pipeAttached; private readonly createSession; private readonly ownsSession; private readonly _isReattach; /** Adopt-mode CLI pid. Pane liveness alone is insufficient because the CLI * can exit back to the user's shell while the tmux pane stays alive. */ private readonly watchCliPid; /** Claude Code session JSONL path — set by worker for claude-code sessions so * the claude-code adapter can verify paste+Enter submissions via file growth. */ claudeJsonlPath?: string; /** PID of the spawned Claude Code child — used by the claude-code adapter to * follow Claude's authoritative session id via ~/.claude/sessions/.json. */ cliPid?: number; /** Working directory the CLI was spawned in — cross-checked against the pid * file's cwd field so a recycled PID can't mislead the resolver. */ cliCwd?: string; /** Whether this backend re-attached to an existing bmx-* tmux session * (rather than creating a new detached one). Mirrors TmuxBackend.isReattach * so the worker can branch on reattach behaviour without a private-cast. */ get isReattach(): boolean; constructor(paneTarget: string, opts?: { createSession?: boolean; ownsSession?: boolean; isReattach?: boolean; cliPid?: number; }); /** spawn() sets up the pipe-pane subscription + fifo reader. In managed * mode it first creates a detached bmx-* tmux session that runs the CLI. */ spawn(bin: string, args: string[], opts: SpawnOpts): void; write(data: string): boolean; sendText(text: string): boolean; sendSpecialKeys(...keys: string[]): boolean; /** * Paste text into the pane via load-buffer + paste-buffer. * The -p flag asks tmux to wrap the buffer in bracketed-paste markers * (\e[200~ … \e[201~) when the application has requested bracketed paste. * This is REQUIRED for CoCo/Ink: without the markers the TUI treats the * paste as a rapid input burst and swallows the trailing Enter as a soft * newline, stranding the message in the input box (it then gets submitted * by the *next* paste — the "replies to the previous message" off-by-one). * NB: this is the default/local tmux runtime backend path (see * selectSessionBackend), not the only backend type in the repo. */ pasteText(text: string): boolean; /** * Run a tmux write (send-keys / load-buffer / paste-buffer) that must never * crash the worker on failure. * * Background: when the CLI process exits mid-write its tmux session/pane is * destroyed, so the very next `tmux send-keys` returns exit 1. The 1s * lifecycle watcher hasn't fired yet, so `this.exited` is still false and the * guard above doesn't help. Previously execFileSync's synchronous throw * propagated through writeInput → flushPending (a fire-and-forget async with * no .catch) → unhandledRejection, which killed the entire worker process * (and with it every Lark session it served). * * Classify the failure instead of letting it escape: * - pane GONE → the CLI exited; convert to a normal onExit so the worker * tears the session down and tells the user "CLI exited", * exactly like the lifecycle watcher would have. * - pane ALIVE → a transient tmux hiccup; log and drop the keystroke. The * claude-code adapter's JSONL retry/verify loop will catch a * non-submission and surface a submit-failure notice. * * Either way this method never throws — every send-keys caller (web-terminal * keys, TUI input, the typing loop) stays crash-safe without its own guard. * * Returns true when the write succeeded, false when it was dropped (pane gone * or a pane-alive hiccup). Callers that verify submission (runner adapters) * read this to report a non-submission; the many fire-and-forget callers * (web-terminal keys, copy-mode) just ignore it. */ private guardedSend; private exitCopyModeIfNeeded; enterCopyMode(): void; sendCopyModeCommand(xCommand: string): void; resize(cols: number, rows: number): void; onData(cb: (data: string) => void): void; onExit(cb: (code: number | null, signal: string | null) => void): void; kill(): void; destroySession(): void; getChildPid(): number | null; getAttachInfo(): null; private startLifecycleWatcher; /** * Probe-interval backoff: 1s while healthy, then 3s / 9s (capped) while probes * keep NOT answering 'exists'. Two purposes: * - stretches the missing-streak teardown window from ~3s to ~13s+, so a * short shared-server stall no longer converts into a fleet-wide teardown; * - sheds probe load exactly when the shared server is struggling — the * per-second re-probes from every worker are themselves connect-storm fuel * (each refused connect frees a backlog slot another worker instantly * takes). * Detection latency for a genuinely dead pane grows to ~13s worst-case, which * is acceptable: writes still fail fast via guardedSend (its own probe fires * teardown immediately when the pane is authoritatively gone), and adopt-mode * pid-death above stays on the ≤ current-interval path. */ private nextProbeDelayMs; /** * Tri-state pane probe. A clean tmux rejection that the SERVER actually * answered is `missing`; timeout, signal, EMFILE/ENFILE and spawn failures * are `unknown`, because the shared server never answered. Collapsing those * into false caused every worker to destroy a live bmx-* session when the * default server had a short outage. * * CONNECTION-level clean failures ("error connecting to ", "lost * server" — see isTmuxServerLevelErrorText) are `unknown` too, flagged * `serverLevel` so the caller can run the dead-server escalation clock: a * briefly stalled server refuses connects instantly (unix-socket backlog * overflow ⇒ clean ECONNREFUSED), which is indistinguishable from a missing * pane by exit status alone. Misreading it as `missing` mass-tore-down every * live session across all bots at once (2026-08-20 incident). */ private probePaneAddressability; /** * Debounce authoritative pane-missing replies. Tearing down on the FIRST * failed probe used to produce spurious disconnects; worse, timeout/EMFILE * were collapsed into the same boolean false and could accumulate to the * threshold. Unknown results now keep the session attached and reset the * destructive streak. Only consecutive `missing` replies plus a final * `missing` confirmation detach the observer. Any success resets. * (pid-death is handled decisively in the watcher — see startLifecycleWatcher.) */ private recordPaneProbe; /** Three-state server reachability cross-check. A running server always has * at least one session (tmux exits when its last session closes), so a clean * `list-sessions` success ⇒ 'up'. The two failure modes must NOT be * collapsed, or the pre-teardown cross-check hangs a sole-session worker: * - 'down': the server answered that it is not running ("no server * running"). Authoritative; for a sole-session socket this happens * because the pane died. Teardown is safe. * - 'unreachable': connect refused / lost server / timeout / spawn failure * — the client never got an answer, so the server may be live-but-stalled. * Stay attached. * Uses the same connection-level classifier as the pane probe so both agree * on what "the client never reached the server" looks like. Never * destructive on its own. */ private static tmuxServerReachability; private stopLifecycleWatcher; private handlePaneExit; private createDetachedSession; private applySessionOptions; /** Snapshot the full pane history WITH ANSI escapes (`-S - -E -`). * * Used by web reattach so a brand-new web client sees the whole prior * conversation. For the screenshot / screen_update fast path use * `captureViewport()` instead — that one only returns the visible pane * and is safe to seed a transient xterm-headless with. * * IMPORTANT: tmux capture-pane separates rows with bare `\n`, no `\r`. * xterm.js (and any VT100-compliant emulator) treats a bare LF as * "move down one row, keep column" — every captured line lands further * to the right than the previous one. Normalising every `\n` to `\r\n` * makes the snapshot render correctly. The live pipe-pane stream itself * doesn't need this fix — applications write proper `\r\n`. */ captureCurrentScreen(): string; /** Escape sequence re-asserting the pane's live input modes (mouse tracking, * app cursor keys, cursor visibility) on a fresh web-terminal xterm. The * worker appends this to write-capable clients' capture-pane seed — without * it a mouse-mode TUI (grok build enables 1003+1006) never receives clicks, * so double-click-to-expand etc. silently do nothing in the web terminal. * Empty string when the pane is gone or tmux can't be queried. */ capturePaneInputModes(): string; /** Snapshot ONLY the currently visible pane (no scrollback). Equivalent to * `tmux capture-pane` with no `-S`/`-E` flags, which defaults to the * viewport. This is the right input for a transient xterm-headless seed: * the snapshot row count matches the transient terminal's row count, so * no normal-buffer scroll happens and the rendered screenshot lines up * with what the user is seeing in the web terminal right now. */ captureViewport(): string; captureInputState(): { viewport: string; cursor: { x: number; y: number; }; } | null; private captureWithBounds; /** Current pane cursor position (0-based, viewport-relative — matches xterm * CUP semantics). Used to restore the cursor in the web-terminal seed so the * CLI's first height-relative redraw lands on the right row. */ private getCursorPosition; /** Current real tmux pane dimensions. Drives transient-renderer sizing so * the screenshot canvas matches whatever the web client resized the pane * to. Returns null if tmux can't be queried (pane gone, server gone). */ getPaneSize(): { cols: number; rows: number; } | null; /** Cheap probe: is the adopted pane currently in the alternate screen * buffer? Used by captureCurrentScreen to decide whether the snapshot * needs an alt-buffer-enter prefix for correct rendering. */ private isPaneInAltBuffer; /** True if the underlying pane is still addressable in tmux. Cheap check — * used by callers to detect "user closed the pane while we were piping". */ isPaneAlive(): boolean; /** Unknown pid → pane-only liveness. EPERM still means the process exists. */ private isCliPidAlive; private fireExit; } //# sourceMappingURL=tmux-pipe-backend.d.ts.map