import { dirname, join } from "node:path"; import { logDirFromEnv, type OrchestratorConfig } from "../config"; import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils"; import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { LOG_DIR } from "./constants"; import { logLines, readLogTail } from "./log-utils"; import { findSessionRecord, isSessionRecordAlive, loadState, logFilePath, readRunnerInfo } from "./runtime"; import { isSessionAlive } from "./sessions"; import type { TerminalInputResult, TerminalInputToken, TerminalSnapshot } from "./types"; export function captureSession( name: string, config: OrchestratorConfig, lines = 100, options: { raw?: boolean } = {}, ): { session: string; lines: string[]; running: boolean } { if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator"); const records = loadState(); const record = records.find((r) => r.name === name); const logFile = record?.logFile ?? logFilePath(name); const running = record ? isSessionRecordAlive(record) : false; let content: string; try { content = readLogTail(logFile); } catch { return { session: name, lines: [], running }; } const allLines = logLines(content, !options.raw); const safeLines = Math.min(Math.max(lines, 1), 1000); return { session: name, lines: allLines.slice(-safeLines), running, }; } // Shared with the runner's logger via the SDK helper, so reader + writer // resolve the same session-mirror filename for a given agent id. function safeMirrorLogName(value: string): string { return sanitizeFsName(value, { replacement: "_", maxLen: 180 }); } // Read the clean, ANSI-free session-mirror diagnostics log for a managed agent. // Accepts either the tmux session name or the agent id; the mirror log is keyed by // agent id. Returns the same shape as captureSession so the proxy is uniform. export function captureSessionMirror( name: string, _config: OrchestratorConfig, lines = 200, ): { session: string; lines: string[]; running: boolean; mirror: true } { const records = loadState(); const record = records.find((r) => r.name === name) ?? records.find((r) => r.agentId === name); const agentId = record?.agentId ?? name; const running = record ? isSessionRecordAlive(record) : false; // The mirror log lives in the same directory as the provider log (both written // by the same user on this host). Derive from the record's logFile when known so // it tracks any per-session log relocation. const logDir = record?.logFile ? dirname(record.logFile) : logDirFromEnv() || LOG_DIR; const mirrorPath = join(logDir, `session-mirror-${safeMirrorLogName(agentId)}.log`); let content: string; try { content = readLogTail(mirrorPath); } catch { return { session: name, lines: [], running, mirror: true }; } const allLines = content.split(/\r?\n/).filter(Boolean); const safeLines = Math.min(Math.max(lines, 1), 2000); return { session: name, lines: allLines.slice(-safeLines), running, mirror: true }; } export function captureTerminal(name: string, config: OrchestratorConfig): TerminalSnapshot { if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator"); const agentAlive = isSessionAlive(name); const socketName = tmuxSocketForSession(name); const running = tmuxHasSession(name, socketName); if (!running) { return { session: name, content: "", running: false, agentAlive, capturedAt: Date.now() }; } const size = tmuxPaneSize(name, socketName); const { content, cursor } = captureConsistent( () => captureContent(name, socketName), () => tmuxCursorPos(name, socketName), ); return { session: name, content, running: true, agentAlive, ...size, ...cursor, capturedAt: Date.now(), }; } // Capture cursor and content *consistently*. They come from separate tmux invocations, // so if the pane scrolls between them (e.g. tool output streaming while the agent // "thinks"), cursorY ends up off-by-one against the captured grid — the parked cursor // then lands a row off and the TUI's next relative redraw stacks a stale statusline row // (bottom-box ghost). Read content, then cursor, then content again; accept only when the // two content reads bracket the cursor read unchanged, which proves the cursor reflects // that exact grid. Fall through with the latest capture if the pane never holds still. export function captureConsistent( readContent: () => string, readCursor: () => { cursorX?: number; cursorY?: number }, maxAttempts = 4, ): { content: string; cursor: { cursorX?: number; cursorY?: number } } { let content = readContent(); let cursor = readCursor(); for (let attempt = 0; attempt < maxAttempts; attempt++) { const recheck = readContent(); if (recheck === content) break; content = recheck; cursor = readCursor(); } return { content, cursor }; } function captureContent(name: string, socketName?: string): string { const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-e", "-S", "-1000", "-t", name), { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim(); throw new Error(stderr || `tmux capture-pane failed with exit code ${result.exitCode}`); } return result.stdout.toString(); } export function terminalInputTokens(data: string): TerminalInputToken[] { const tokens: TerminalInputToken[] = []; let literal = ""; const flushLiteral = () => { if (!literal) return; tokens.push({ type: "literal", value: literal }); literal = ""; }; const escapeSequences: Array<[string, string]> = [ ["\x1b[A", "Up"], ["\x1b[B", "Down"], ["\x1b[C", "Right"], ["\x1b[D", "Left"], ["\x1b[H", "Home"], ["\x1b[F", "End"], ["\x1b[3~", "Delete"], ]; for (let index = 0; index < data.length;) { const match = escapeSequences.find(([sequence]) => data.startsWith(sequence, index)); if (match) { flushLiteral(); tokens.push({ type: "key", value: match[1] }); index += match[0].length; continue; } const ch = data[index]!; if (ch === "\r" || ch === "\n") { flushLiteral(); tokens.push({ type: "key", value: "Enter" }); } else if (ch === "\t") { flushLiteral(); tokens.push({ type: "key", value: "Tab" }); } else if (ch === "\u0003") { flushLiteral(); tokens.push({ type: "key", value: "C-c" }); } else if (ch === "\u007f" || ch === "\b") { flushLiteral(); tokens.push({ type: "key", value: "BSpace" }); } else if (ch === "\x1b") { flushLiteral(); tokens.push({ type: "key", value: "Escape" }); } else if (ch >= " " || ch > "\x7f") { literal += ch; } index += 1; } flushLiteral(); return tokens; } // Validation contract shared by the HTTP terminal routes and the websocket terminal // frames (orchestrator/src/api.ts). Both transports MUST enforce the same envelope — // keep these the single source of truth (see #143). Pure: no tmux, safe to unit-test. const TERMINAL_INPUT_MAX = 4096; export function validateTerminalInputData(input: unknown): string { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("terminal input body must be an object"); const data = (input as { data?: unknown }).data; if (typeof data !== "string") throw new Error("terminal input data must be a string"); if (data.length > TERMINAL_INPUT_MAX) throw new Error(`terminal input exceeds ${TERMINAL_INPUT_MAX} characters`); return data; } export function validateTerminalResize(input: unknown): { cols: number; rows: number } { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("resize body must be an object"); const cols = (input as { cols?: unknown }).cols; const rows = (input as { rows?: unknown }).rows; // typeof narrows for the bounds comparison; Number.isFinite additionally rejects // NaN/Infinity — without it NaN slips past the bounds check below (every NaN // comparison is false), the exact malformed-resize frame the websocket path used to // forward via Number(frame.cols). if (typeof cols !== "number" || typeof rows !== "number" || !Number.isFinite(cols) || !Number.isFinite(rows)) { throw new Error("cols and rows must be numbers"); } if (cols < 10 || cols > 500 || rows < 5 || rows > 200) throw new Error("cols must be 10-500, rows must be 5-200"); return { cols: Math.round(cols), rows: Math.round(rows) }; } export function sendTerminalInput(name: string, config: OrchestratorConfig, input: unknown): TerminalInputResult { if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator"); const socketName = tmuxSocketForSession(name); if (!tmuxHasSession(name, socketName)) throw new Error("terminal session is not running"); const data = validateTerminalInputData(input); const tokens = terminalInputTokens(data); for (const token of tokens) { const args = token.type === "literal" ? tmuxCommand(socketName, "send-keys", "-t", name, "-l", token.value) : tmuxCommand(socketName, "send-keys", "-t", name, token.value); const result = Bun.spawnSync(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim(); throw new Error(stderr || `tmux send-keys failed with exit code ${result.exitCode}`); } } return { session: name, running: true, sent: tokens.length, capturedAt: Date.now(), }; } export function resizeTerminal(name: string, config: OrchestratorConfig, input: unknown): { session: string; cols: number; rows: number } { if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator"); const socketName = tmuxSocketForSession(name); if (!tmuxHasSession(name, socketName)) throw new Error("terminal session is not running"); const clamped = validateTerminalResize(input); const result = Bun.spawnSync(tmuxCommand(socketName, "resize-window", "-t", name, "-x", String(clamped.cols), "-y", String(clamped.rows)), { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim(); throw new Error(stderr || `tmux resize-window failed with exit code ${result.exitCode}`); } return { session: name, ...clamped }; } export function tmuxSocketForSession(name: string): string | undefined { const record = loadState().find((item) => item.name === name); return record ? readRunnerInfo(record)?.tmuxSocket : undefined; } // Shared tmux helpers; tmuxCommand re-exported for ./terminal-stream. export { tmuxCommand }; // Lightweight liveness for the live terminal stream's backfill metadata — avoids a full // capture-pane just to learn whether the pane/agent are still up. export function sessionLiveness(name: string, socketName = tmuxSocketForSession(name)): { running: boolean; agentAlive: boolean } { return { running: tmuxHasSession(name, socketName), agentAlive: isSessionAlive(name) }; } function tmuxPaneSize(name: string, socketName?: string): { cols?: number; rows?: number } { const result = Bun.spawnSync(tmuxCommand(socketName, "display-message", "-p", "-t", name, "#{pane_width} #{pane_height}"), { stdin: "ignore", stdout: "pipe", stderr: "ignore", }); if (result.exitCode !== 0) return {}; const [colsRaw, rowsRaw] = result.stdout.toString().trim().split(/\s+/, 2); const cols = Number(colsRaw); const rows = Number(rowsRaw); return { ...(Number.isFinite(cols) && cols > 0 ? { cols } : {}), ...(Number.isFinite(rows) && rows > 0 ? { rows } : {}), }; } function tmuxCursorPos(name: string, socketName?: string): { cursorX?: number; cursorY?: number } { const result = Bun.spawnSync(tmuxCommand(socketName, "display-message", "-p", "-t", name, "#{cursor_x} #{cursor_y}"), { stdin: "ignore", stdout: "pipe", stderr: "ignore", }); if (result.exitCode !== 0) return {}; const [xRaw, yRaw] = result.stdout.toString().trim().split(/\s+/, 2); const cursorX = Number(xRaw); const cursorY = Number(yRaw); return { ...(Number.isFinite(cursorX) ? { cursorX } : {}), ...(Number.isFinite(cursorY) ? { cursorY } : {}), }; }