UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

564 lines 22 kB
/** * PTY-backed terminal session manager used by the dashboard. * It validates runner and project inputs, spawns CLI sessions, and brokers WebSocket traffic. */ import { randomUUID } from "node:crypto"; import { extname } from "node:path"; import { execFileSync } from "node:child_process"; import { decodeClientMessage } from "./decoders.js"; import { getAgentProfiles } from "../agents/registry.js"; import { validateProjectPath } from "./local-paths.js"; /** Maximum number of concurrent terminal sessions allowed. * Single source of truth consumed by the dashboard API, client guards, and docs. */ export const MAX_SESSIONS = 10; const DEFAULT_IDLE_TIMEOUT_MINUTES = 480; // Default limit: one workday keeps abandoned PTYs from surviving overnight. const WINDOWS_RUNNER_EXTENSION_PRIORITY = [ ".exe", ".cmd", ".bat", ".com", ".ps1", ]; const WINDOWS_TERMINAL_SHELL = "powershell.exe"; const POSIX_PROMPT_ENV_CLEANUP = "unset GOAT_RUNNER"; const WINDOWS_PROMPT_ENV_CLEANUP = "Remove-Item Env:GOAT_RUNNER -ErrorAction SilentlyContinue"; const CODEX_DASHBOARD_ARGS = "--sandbox danger-full-access"; const INITIAL_PROMPT_AFTER_OUTPUT_DELAY_MS = 150; const INITIAL_PROMPT_FALLBACK_DELAY_MS = 5000; export const INITIAL_PROMPT_CHUNK_SIZE = 2048; const DETACH_BUFFER_LIMIT = 512 * 1024; // Buffer limit: 512KB preserves reconnect scrollback without unbounded server memory. /** Format a full prompt as one terminal paste submitted once to the runner. */ function formatInitialPromptInput(prompt) { return "\x1b[200~" + prompt + "\x1b[201~" + "\r"; } /** * Split terminal input into bounded chunks for PTY write reliability. * * @param input Full terminal payload to write. * @param chunkSize Maximum characters per PTY write; must be a positive integer. * @returns Ordered chunks that concatenate back to the original input. * @throws Error when `chunkSize` is not a positive integer. */ export function chunkTerminalInput(input, chunkSize = INITIAL_PROMPT_CHUNK_SIZE) { if (!Number.isInteger(chunkSize) || chunkSize <= 0) { throw new Error("chunkSize must be a positive integer"); } const chunks = []; for (let index = 0; index < input.length; index += chunkSize) { chunks.push(input.slice(index, index + chunkSize)); } return chunks; } /** * Pick the most runnable Windows runner path from a `where` result set. * * @param candidates Raw paths returned by `where`, including possible blank or duplicate lines. * @returns The preferred executable-like path, or null when nothing usable remains. */ export function pickWindowsRunnerPath(candidates) { const cleaned = Array.from(new Set(candidates .map((candidate) => candidate.trim()) .filter((candidate) => { return candidate.length > 0; }))); if (cleaned.length === 0) return null; const rank = (candidate) => { const ext = extname(candidate).toLowerCase(); const index = WINDOWS_RUNNER_EXTENSION_PRIORITY.indexOf(ext); return index === -1 ? WINDOWS_RUNNER_EXTENSION_PRIORITY.length : index; }; cleaned.sort((left, right) => rank(left) - rank(right)); return cleaned[0] ?? null; } /** Build the runner command embedded in the shell wrapper. */ function terminalRunnerCommand(runner, platform) { if (runner !== "codex") { return platform === "win32" ? "& $env:GOAT_RUNNER" : '"$GOAT_RUNNER"'; } return platform === "win32" ? `& $env:GOAT_RUNNER ${CODEX_DASHBOARD_ARGS}` : `"$GOAT_RUNNER" ${CODEX_DASHBOARD_ARGS}`; } /** * Build the PTY shell invocation that keeps a usable terminal open per OS. * * @param runner Runner identity used for runner-specific launch flags. * @param cliPath Absolute runner binary path to launch inside the shell. * @param prompt Optional launch prompt delivered through PTY input after startup. * @param environment Environment snapshot merged into the spawned process. * @param platform Platform selector used by tests and cross-platform launch planning. * @returns Spawn details plus deferred initial input; callers own the actual PTY spawn. */ export function buildTerminalSpawnSpec(runner, cliPath, prompt, environment = process.env, platform = process.platform) { const hasPrompt = prompt.length > 0; const env = { ...environment, GOAT_RUNNER: cliPath, }; const initialInput = hasPrompt ? formatInitialPromptInput(prompt) : null; if (platform === "win32") { return { shell: WINDOWS_TERMINAL_SHELL, args: [ "-NoLogo", "-NoExit", "-Command", `try { ${terminalRunnerCommand(runner, platform)} } finally { ${WINDOWS_PROMPT_ENV_CLEANUP} }`, ], env, initialInput, }; } const configuredShell = environment.SHELL; const shell = configuredShell?.length ? configuredShell : "/bin/bash"; const shellCmd = `${terminalRunnerCommand(runner, platform)}; ${POSIX_PROMPT_ENV_CLEANUP}; exec "$SHELL" -i`; return { shell, args: ["-c", shellCmd], env: { ...env, SHELL: shell, }, initialInput, }; } /** * Resolve a CLI binary by reading the process PATH through platform lookup tools. * Reads process state only; swallows lookup errors and reports them as null because missing runners are normal dashboard state. */ function resolveCLIPath(name) { if (process.platform === "win32") { try { const candidates = execFileSync("where", [name], { encoding: "utf-8", timeout: 5000, }) .split(/\r?\n/) .map((candidate) => candidate.trim()) .filter(Boolean); const preferred = pickWindowsRunnerPath(candidates); if (preferred) return preferred; return null; } catch { /* passive detection only; do not execute runner binaries at startup */ return null; } } try { return execFileSync("which", [name], { encoding: "utf-8", timeout: 5000, }).trim(); } catch { try { return (execFileSync("where", [name], { encoding: "utf-8", timeout: 5000 }) .trim() .split("\n")[0] ?.trim() ?? null); } catch { return null; } } } /** Clamp a terminal dimension to a safe integer range. */ function clampDim(dimensionValue, max, fallback) { return Number.isInteger(dimensionValue) && dimensionValue > 0 && dimensionValue <= max ? dimensionValue : fallback; } /** Send a terminal message when the browser socket is still open. */ function sendMessage(socket, msg) { if (socket.readyState === 1) { // WebSocket.OPEN socket.send(JSON.stringify(msg)); } } /** Detect bracketed paste sends so trace output can distinguish launch prompts from typing. */ function looksLikePromptSend(input) { return input.includes("\x1b[200~"); } /** Manages PTY-backed terminal sessions for the dashboard */ class TerminalManager { sessions = new Map(); runnerPaths = new Map(); nodePtyModule = null; nodePtyAvailable = null; startedAt = Date.now(); idleTimeoutMs; traceSink; /** Resolve available runner binaries once and convert idle-timeout minutes into timer state. */ constructor(idleTimeoutMinutes, traceSink) { const minutes = idleTimeoutMinutes ?? DEFAULT_IDLE_TIMEOUT_MINUTES; this.idleTimeoutMs = minutes === 0 ? null : minutes * 60 * 1000; this.traceSink = traceSink ?? null; // Resolve all runner CLI paths at startup for (const profile of getAgentProfiles()) { const path = resolveCLIPath(profile.terminalBinary); if (path) this.runnerPaths.set(profile.id, path); } } /** Lazy-load node-pty on first use; throws a rebuild diagnostic when the native module is missing. */ async loadNodePty() { if (this.nodePtyModule) return this.nodePtyModule; try { this.nodePtyModule = await import("node-pty"); this.nodePtyAvailable = true; return this.nodePtyModule; } catch { this.nodePtyAvailable = false; throw new Error("node-pty failed to load. Run: npm rebuild node-pty (requires C++ build tools)"); } } /** Create a new terminal session for the requested runner and project. */ async create(prompt, projectPath, runner = "claude", options = {}) { const activeSessions = Array.from(this.sessions.values()).filter((s) => s.status !== "terminated").length; if (activeSessions >= MAX_SESSIONS) { throw new Error(`Maximum ${MAX_SESSIONS} concurrent sessions. Kill an existing session first.`); } const cliPath = this.runnerPaths.get(runner); if (!cliPath) { console.warn(`[terminal] Runner "${runner}" not found. Available: ${[...this.runnerPaths.keys()].join(", ")}`); throw new Error(`${runner} CLI not found. Install it first.`); } const validatedCwd = validateProjectPath(projectPath); const validatedTarget = validateProjectPath(options.targetPath || validatedCwd); const nodePty = await this.loadNodePty(); const id = randomUUID(); const spawnSpec = buildTerminalSpawnSpec(runner, cliPath, prompt); console.log(`[terminal] Starting ${runner} session in ${validatedCwd} for target ${validatedTarget}`); const pty = nodePty.spawn(spawnSpec.shell, spawnSpec.args, { name: "xterm-256color", cols: 80, rows: 24, cwd: validatedCwd, env: spawnSpec.env, }); let hasInitialInputSent = false; let initialInputTimer = null; const initialInputLatestDueAt = Date.now() + INITIAL_PROMPT_FALLBACK_DELAY_MS; let initialInputDueAt = 0; const session = { id, status: "active", createdAt: new Date().toISOString(), projectPath: validatedTarget, cwd: validatedCwd, targetPath: validatedTarget, runner, lastInputAt: Date.now(), pty, ws: null, idleTimer: null, detachBuffer: [], detachBufferSize: 0, }; /** Send the launch prompt through the PTY, avoiding shell/native argv limits. */ const sendInitialInput = () => { if (!spawnSpec.initialInput || hasInitialInputSent) return; const pty = session.pty; if (session.status === "terminated" || !pty) return; hasInitialInputSent = true; if (initialInputTimer) { clearTimeout(initialInputTimer); initialInputTimer = null; initialInputDueAt = 0; } for (const chunk of chunkTerminalInput(spawnSpec.initialInput)) { pty.write(chunk); } session.lastInputAt = Date.now(); }; /** Schedule initial prompt delivery after the runner has had time to draw. */ const scheduleInitialInput = (delayMs, { reset = false } = {}) => { if (!spawnSpec.initialInput || hasInitialInputSent) return; const now = Date.now(); const boundedDelayMs = Math.max(0, Math.min(delayMs, initialInputLatestDueAt - now)); const nextDueAt = now + boundedDelayMs; if (initialInputTimer) { if (!reset && initialInputDueAt <= nextDueAt) return; clearTimeout(initialInputTimer); } initialInputDueAt = nextDueAt; initialInputTimer = setTimeout(sendInitialInput, boundedDelayMs); }; // Wire PTY output at creation - routes to WebSocket if attached, buffer if detached pty.onData((data) => { scheduleInitialInput(INITIAL_PROMPT_AFTER_OUTPUT_DELAY_MS, { reset: true, }); if (session.ws) { this.resetIdleTimer(session); sendMessage(session.ws, { type: "output", data }); } else if (session.detachBufferSize < DETACH_BUFFER_LIMIT) { session.detachBuffer.push(data); session.detachBufferSize += data.length; } }); pty.onExit(({ exitCode, signal }) => { session.status = "terminated"; if (initialInputTimer) { clearTimeout(initialInputTimer); initialInputTimer = null; initialInputDueAt = 0; } if (session.ws) { sendMessage(session.ws, { type: "exit", code: exitCode, signal: signal?.toString() ?? null, }); } this.clearIdleTimer(session); }); this.sessions.set(id, session); this.resetIdleTimer(session); scheduleInitialInput(INITIAL_PROMPT_FALLBACK_DELAY_MS); return { id, status: session.status, wsUrl: `/ws/terminal/${id}`, }; } /** * Attach a browser WebSocket to an existing terminal session. * Reports an error on the socket when the session is gone; the branching preserves detach semantics * because a browser disconnect must not be treated as a PTY exit. */ attachWebSocket(id, socket) { const session = this.sessions.get(id); if (!session || session.status === "terminated") { sendMessage(socket, { type: "error", message: "Session not found or already terminated", }); socket.close(); return; } // Only one browser owns live output at a time; reconnects replace stale sockets while the PTY keeps running. if (session.ws) { try { session.ws.close(); } catch { /* already closed */ } } session.ws = socket; this.replayDetachBuffer(session, socket); socket.on("message", (raw) => { this.handleClientMessage(session, socket, raw); }); // WebSocket close means browser detach, not process exit; only the active socket may detach itself. socket.on("close", () => { if (session.ws === socket) { session.ws = null; } }); } /** * Replay buffered PTY output to a newly attached socket so reconnects do not * lose terminal context gathered while detached, then drop the buffer. * * @param session - terminal session holding the detach buffer * @param socket - freshly attached browser WebSocket to replay into */ replayDetachBuffer(session, socket) { if (session.detachBuffer.length === 0) return; for (const chunk of session.detachBuffer) { sendMessage(socket, { type: "output", data: chunk }); } session.detachBuffer = []; session.detachBufferSize = 0; } /** * Handle one client WebSocket payload: input keystrokes feed the PTY (with * idle-timer reset and prompt tracing), resize messages clamp and apply * terminal dimensions, and undecodable payloads report an error to the socket. * * @param session - terminal session that owns the PTY the message targets * @param socket - browser WebSocket the payload arrived on * @param raw - wire payload as received (Buffer or string) */ handleClientMessage(session, socket, raw) { const text = typeof raw === "string" ? raw : raw.toString("utf-8"); const decoded = decodeClientMessage(text); if (!decoded.ok) { sendMessage(socket, { type: "error", message: `${decoded.path}: ${decoded.error}`, }); return; } const msg = decoded.value; if (msg.type === "input") { session.lastInputAt = Date.now(); this.resetIdleTimer(session); this.traceTerminalInput(session, "terminal.send", msg.data); if (looksLikePromptSend(msg.data)) { this.traceTerminalInput(session, "prompt.send", msg.data); } session.pty?.write(msg.data); return; } session.pty?.resize(clampDim(msg.cols, 500, 80), clampDim(msg.rows, 200, 24)); } /** Return the public session snapshot for one terminal session ID. */ get(id) { const session = this.sessions.get(id); if (!session) return null; return this.toInfo(session); } /** Terminate a terminal session by ID. */ kill(id) { const session = this.sessions.get(id); if (!session) return false; this.killSession(session); return true; } /** List every terminal session that is still considered live. */ list() { return Array.from(this.sessions.values()) .filter((s) => s.status !== "terminated") .map((s) => this.toInfo(s)); } /** Report terminal backend health; node-pty probe errors recover into an unavailable status. */ async health() { // Probe node-pty availability on first health check if (this.nodePtyAvailable === null) { try { await this.loadNodePty(); } catch { /* sets nodePtyAvailable = false */ } } const platform = process.platform; const platformHint = platform === "linux" || platform === "darwin" || platform === "win32" ? platform : undefined; return { uptime: Math.floor((Date.now() - this.startedAt) / 1000), activeSessions: Array.from(this.sessions.values()).filter((s) => s.status === "active").length, nodePtyAvailable: this.nodePtyAvailable ?? false, availableRunners: Array.from(this.runnerPaths.keys()), platformHint, idleTimeoutMinutes: this.idleTimeoutMs === null ? 0 : Math.round(this.idleTimeoutMs / 60000), }; } /** Shut down every tracked session and notify attached clients. */ shutdown() { for (const session of this.sessions.values()) { if (session.ws) { sendMessage(session.ws, { type: "shutdown" }); } this.killSession(session); } } /** Tear down a terminal session; swallows kill/close races because either side may already be gone. */ killSession(session) { this.clearIdleTimer(session); if (session.pty && session.status !== "terminated") { session.status = "terminated"; try { session.pty.kill(); } catch { /* already dead */ } } if (session.ws) { try { session.ws.close(); } catch { /* already closed */ } session.ws = null; } this.sessions.delete(session.id); } /** Emit redaction-ready input metadata; tracing errors never affect PTY writes. */ traceTerminalInput(session, eventKind, input) { try { this.traceSink?.({ eventKind, sessionId: session.id, projectPath: session.projectPath, cwd: session.cwd, targetPath: session.targetPath, runner: session.runner, input, bytes: Buffer.byteLength(input, "utf-8"), }); } catch { /* trace sink failures must not affect terminal input */ } } /** Reset the idle-timeout timer after activity because each session must have at most one expiry path. */ resetIdleTimer(session) { this.clearIdleTimer(session); if (this.idleTimeoutMs === null) return; const timeoutMs = this.idleTimeoutMs; const totalMins = Math.round(timeoutMs / 60000); const hours = Math.floor(totalMins / 60); const minutes = totalMins % 60; const label = hours > 0 && minutes > 0 ? `${hours}h ${minutes} min` : hours > 0 ? `${hours}h` : `${totalMins} min`; session.idleTimer = setTimeout(() => { if (session.ws) { sendMessage(session.ws, { type: "error", message: `Session killed: idle timeout (${label})`, }); } this.killSession(session); }, timeoutMs); } /** Clear the idle-timeout timer for a session. */ clearIdleTimer(session) { if (session.idleTimer) { clearTimeout(session.idleTimer); session.idleTimer = null; } } /** Convert an internal session record into its public response shape. */ toInfo(session) { return { id: session.id, status: session.status, createdAt: session.createdAt, projectPath: session.projectPath, cwd: session.cwd, targetPath: session.targetPath, runner: session.runner, lastInputAt: session.lastInputAt, }; } } export { TerminalManager, resolveCLIPath, validateProjectPath }; //# sourceMappingURL=terminal.js.map