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.

344 lines (343 loc) 18.8 kB
"use strict"; /** * Browser-side terminal/session helpers for the dashboard Alpine app. * The Alpine app owns view state; this file owns xterm/WebSocket mechanics. */ const TERMINAL_REFIT_RETRY_DELAY_MS = 50; // Retry budget: one render tick before measuring xterm again. const TERMINAL_REFIT_MAX_ATTEMPTS = 20; // Retry cap: one second is enough for hidden panels to become measurable. const TERMINAL_INITIAL_FIT_DELAYS_MS = [50, 200, 500]; const TERMINAL_LAUNCH_PROMPT_NO_OUTPUT_FALLBACK_DELAY_MS = 6000; // Fallback delay: silent runner startup can precede the first composer prompt. const TERMINAL_LAUNCH_PROMPT_AFTER_OUTPUT_FALLBACK_DELAY_MS = 2000; // Fallback budget: after output appears, wait for a recognised composer marker. const TERMINAL_LAUNCH_PROMPT_QUIET_DELAY_MS = 500; // Quiet budget: send after output pauses to avoid racing TUI redraws. const TERMINAL_PASTE_MARKER_SETTLE_DELAY_MS = 300; // Marker-settle budget: Claude Code v2.1.152 can swallow immediate Enter after fat pasted-text echoes; 300ms is the capped fallback until live Delta A is re-measured. const TERMINAL_CLAUDE_PASTE_NO_MARKER_FALLBACK_DELAY_MS = 1500; // Claude fallback: if the visible pasted-text marker is not detected, send Enter soon after the paste instead of waiting for the generic 15s safety net. const TERMINAL_PASTE_COMMIT_FALLBACK_DELAY_MS = 15000; // Fallback budget: runners without paste echoes still need eventual Enter submission. const TERMINAL_PASTE_FALLBACK_RELEASE_DELAY_MS = 5000; // Release budget: do not hold queued paste state forever after fallback submission. const TERMINAL_PASTE_SUBMIT_RETRY_CADENCE_MS = 500; // Composer retry cadence: bounded re-Enter loop after a stuck pasted-text marker; distinct from websocket-write retry. const TERMINAL_PASTE_SUBMIT_RETRY_DELAY_MS = 300; // Retry budget: keep Enter retries responsive without flooding the PTY. const TERMINAL_PASTE_SUBMIT_MAX_RETRIES = 5; // Retry cap: bounded Enter retries avoid stuck composer state without spamming input. const AWAITING_INPUT_VISIBLE_DELAY_MS = 1200; const TERMINAL_LOADING_SLOW_HINT_MS = 3000; // Hint delay: normal xterm startup should finish before slow-load copy appears. const TERMINAL_LOADING_RETRY_MS = 10000; // Retry budget: give asset loading and socket attach time before offering retry. // Coalesce bursty launch/route refreshes without delaying user-visible state. const SESSION_REFRESH_DEBOUNCE_MS = 50; const BRACKETED_PASTE_MARKER_PATTERN = /\x1b\[(?:200|201)~/g; const RUNNER_STARTUP_FAILURE_MESSAGE = "Runner failed before prompt delivery. Check the terminal output above."; // eslint-disable-next-line prefer-const -- false positive because dashboard-terminal-runtime.ts reassigns xtermLoadPromise across the classic-script bundle, which this file cannot see. let xtermLoadPromise = null; // eslint-disable-next-line prefer-const -- false positive because dashboard-terminal-runtime.ts reassigns sessionRefreshPromise across the classic-script bundle, which this file cannot see. let sessionRefreshPromise = null; // eslint-disable-next-line prefer-const -- false positive because dashboard-terminal-runtime.ts reassigns sessionRefreshDebounceTimer across the classic-script bundle, which this file cannot see. let sessionRefreshDebounceTimer = null; /** Return the dashboard workspace that owns the shipped goat skills. */ function dashboardControllingWorkspace() { return window.__GOAT_FLOW_DEFAULT_PATH__ ?? "."; } /** Return a POSIX-shell-safe single-quoted string for command examples. */ function dashboardShellQuote(commandText) { return `'${commandText.replace(/'/g, "'\\''")}'`; } /** Remove generic labels that hide the actual session identity. */ function dashboardMeaningfulSessionTitle(title) { const trimmed = typeof title === "string" ? title.trim() : ""; if (!trimmed) return null; if (/^(terminal|terminal session|session)$/i.test(trimmed)) return null; return trimmed; } /** Build a non-generic fallback when no launch-time title is available. */ function dashboardFallbackSessionTitle(runner, id) { const suffix = id ? id.slice(0, 8) : "new"; return `${runner || "runner"} session ${suffix}`; } /** Persist a launch-time session title so reconnects do not collapse to "Terminal". */ function dashboardRememberSessionTitle(ctx, sessionId, title) { const meaningful = dashboardMeaningfulSessionTitle(title); if (!meaningful) return; const next = { ...ctx.sessionTitles, [sessionId]: meaningful }; const entries = Object.entries(next).slice(-80); ctx.sessionTitles = Object.fromEntries(entries); localStorage.setItem("goat-flow-session-titles", JSON.stringify(ctx.sessionTitles)); } /** Keep a short client-side history for sessions that the backend no longer lists. */ function dashboardRememberRecentSession(ctx, session) { ctx.rememberSessionTitle(session.id, session.promptLabel); const recent = { id: session.id, status: "terminated", createdAt: new Date(session.startTime).toISOString(), projectPath: session.projectPath, cwd: session.cwd, targetPath: session.targetPath, runner: session.runner, lastInputAt: session.lastInputTime, age: Math.max(0, Math.floor((Date.now() - session.startTime) / 1000)), projectName: ctx.displayNameFor(session.projectPath), }; ctx.recentTerminalSessions = [ recent, ...ctx.recentTerminalSessions.filter((item) => item.id !== session.id), ].slice(0, 8); } /** Resolve the title shown for local and server-backed terminal sessions. */ function dashboardSessionTitle(ctx, session) { if (!session) return "Runner session"; const local = ctx.sessions.find((s) => s.id === session.id); return (dashboardMeaningfulSessionTitle(local?.promptLabel) || dashboardMeaningfulSessionTitle("promptLabel" in session ? session.promptLabel : null) || dashboardMeaningfulSessionTitle(ctx.sessionTitles[session.id]) || dashboardFallbackSessionTitle(session.runner, session.id)); } /** Strip common terminal control codes before scanning output text. */ function dashboardPlainTerminalText(text) { return (text // OSC (title / hyperlink / progress): ESC ] ... BEL or ESC ] ... ESC \. // Title text is captured separately via dashboardTerminalTitlesFromOutput. .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") .replace(/\x1b\[(\d+)C/g, (_sequence, count) => " ".repeat(Math.min(Number.parseInt(count, 10), 240))) .replace(/\x1b\[C/g, " ") // CUP / HVP (cursor position). Codex lays out every word with `ESC[r;cH` // and never emits `\r\n` between rows; without this normalisation those // positionings collapse `1. Yes\x1b[9;3H2. No` onto one line, breaking // the numbered-choices regex that requires a newline between options. // Replace with `\n ` so cross-row positionings produce line breaks and // intra-row positionings still leave a token boundary. .replace(/\x1b\[\d*(?:;\d*)?[Hf]/g, "\n ") // CHA (cursor horizontal absolute). Replace with a single space so // column-laid words (Claude Code's "Esc to cancel · Tab to amend" footer // and numbered choices) keep a token boundary instead of collapsing. .replace(/\x1b\[\d*G/g, " ") .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") // Unicode box-drawing characters. Copilot and Gemini wrap their approval // dialogs in `│ ... │` borders; the leading `│` prevents `numberedChoices` // from matching `\n\s*1.` since `│` is not in `\s`. Replace with a space. .replace(/[─-╿]/g, " ") .replace(/\r/g, "\n")); } /** Extract OSC 0/1/2 title payloads from raw terminal output. */ function dashboardTerminalTitlesFromOutput(text) { const titles = []; const pattern = /\x1b\][012];([^\x07\x1b]*)(?:\x07|\x1b\\)/g; for (const match of text.matchAll(pattern)) { const payload = match[1]?.trim(); if (payload) titles.push(payload); } return titles; } /** Return true when an OSC title signals the runner is blocked on user input. */ function dashboardTerminalTitleSuggestsAwaitingInput(title) { return (/\baction required\b/i.test(title) || /\[\s*!\s*\]/.test(title) || /\bawaiting (?:input|confirmation|approval)\b/i.test(title)); } /** Prepare user prompt text for one bracketed-paste payload. */ function dashboardPreparePasteBody(text) { return text .replace(/\r\n?/g, "\n") .replace(BRACKETED_PASTE_MARKER_PATTERN, ""); } /** Return the last permission prompt intro in plain terminal text. */ function dashboardLastCommandPermissionPromptIndex(plain) { let lastIndex = -1; const patterns = [ /\bdo\s+you\s+want\s+to\s+(?:proceed|continue|allow|approve|run\s+(?:this\s+)?command)\??/gi, /\bwould\s+you\s+like\s+to\s+run\s+the\s+following\s+command\??/gi, /\ballow\s+execution\s+of\b/gi, // Trust dialogs: every runner shows one on first launch in a fresh cwd. // Codex/Copilot/Gemini phrase it as "Do you trust …"; Claude Code shows // "Is this a project you created or one you trust?". /\bdo\s+you\s+trust\s+(?:the\s+)?(?:files|contents|this\s+(?:folder|directory))\b/gi, /\bis\s+this\s+a\s+project\s+you\s+(?:created|trust)\b/gi, /\bconfirm\s+folder\s+trust\b/gi, ]; for (const pattern of patterns) { for (const match of plain.matchAll(pattern)) { lastIndex = Math.max(lastIndex, match.index); } } return lastIndex; } /** Return true when the visible tail ends with a prompt that is not complete yet. */ function dashboardOutputTailEndsWithAwaitingInputStart(text) { const plain = dashboardPlainTerminalText(text).slice(-1200).trimEnd(); const promptIndex = dashboardLastCommandPermissionPromptIndex(plain); if (promptIndex < 0) return false; const promptTail = plain.slice(promptIndex); if (promptTail.length > 700) return false; return !dashboardOutputLooksAwaitingInput(promptTail); } /** * Footer pattern that signals "we are parked on a confirmation prompt." * Covers Claude Code's trust dialog (`Enter to confirm`), Codex's trust dialog * (`Press enter to continue`), and Copilot's selection menu * (`↑/↓ to navigate · enter to select · esc to cancel`). */ function dashboardOutputHasConfirmFooter(plain) { return (/\bPress\s+enter\s+to\s+(?:continue|confirm|select)\b/i.test(plain) || /\bEnter\s+to\s+(?:confirm|select|continue)\b/i.test(plain) || /\benter\s+to\s+select\b/i.test(plain)); } /** Heuristic for runner approval prompts because each agent renders choices differently. */ function dashboardOutputLooksAwaitingInput(text) { const plain = dashboardPlainTerminalText(text); const titleSignal = dashboardTerminalTitlesFromOutput(text).some(dashboardTerminalTitleSuggestsAwaitingInput); const numberedChoices = /(^|\n)\s*(?:[›>❯▶▸→●]\s*)?1[.)]\s+\S[\s\S]{0,900}\n\s*(?:[›>❯▶▸→●]\s*)?2[.)]\s+\S/i.test(plain); const choicePrompt = /\b(?:choose|select|pick)\s+(?:an?\s+)?(?:option|choice)\b/i.test(plain) || /\b(?:enter|type)\s+(?:the\s+)?(?:number|choice|option)\b/i.test(plain) || /\bwhich option\b/i.test(plain); const commandPermissionPrompt = dashboardLastCommandPermissionPromptIndex(plain) >= 0; const confirmFooter = dashboardOutputHasConfirmFooter(plain); return (titleSignal || /\bawaiting (?:input|confirmation|approval)\b/i.test(plain) || /\bEsc\s+to\s+cancel\b[\s\S]{0,240}\bTab\s+to\s+amend\b/i.test(plain) || (commandPermissionPrompt && numberedChoices) || (choicePrompt && numberedChoices) || (confirmFooter && numberedChoices)); } /** Return true for chunks that complete a permission prompt started earlier. */ function dashboardOutputLooksAwaitingInputContinuation(text) { const plain = dashboardPlainTerminalText(text); return (/(^|\n)\s*(?:[›>❯▶▸→●]\s*)?1[.)]\s+\S[\s\S]{0,900}\n\s*(?:[›>❯▶▸→●]\s*)?2[.)]\s+\S/i.test(plain) || /(^|\n)\s*(?:[›>❯▶▸→●]\s*)?[23][.)]\s+\S/i.test(plain) || /\bEsc\s+to\s+(?:cancel|stop)\b/i.test(plain) || /\bPress\s+enter\s+to\s+(?:confirm|continue|select)\b/i.test(plain) || /\bEnter\s+to\s+(?:confirm|select|continue)\b/i.test(plain) || /\bAllow once\b/i.test(plain)); } /** Return true for single-frame runner status redraws that should not clear waiting state. */ function dashboardOutputLooksTransientStatusRedraw(text) { const plain = dashboardPlainTerminalText(text).trim(); if (!plain) return true; if (/^\r[^\n\r]*$/u.test(text)) return true; // Bare spinner-glyph frame emitted ~2 Hz while a prompt is visible. Claude // Code paints `●` (U+25CF), Codex paints `◦` (U+25E6), other runners use // braille patterns (U+2800–U+28FF). Without this branch every other spinner // tick fell through the classifier and killed the 1200ms reveal timer, so // the badge never fired. Match the glyph in isolation, optionally repeated, // with no other text. if (/^[●✻✢✳✶*•·◦◯○◎⊙◌⠀-⣿]+$/u.test(plain)) return true; return /^[●✻✢✳✶*•·◦◯○◎⊙◌]?\s*(?:Thinking|Processing|Checking|Reading|Searching|Working|Loading|Generating)\b/iu.test(plain); } /** Return true when a server error proves the PTY session is no longer live. */ function dashboardTerminalErrorEndsSession(message) { return (/\bSession not found or already terminated\b/i.test(message) || /\bSession killed: idle timeout\b/i.test(message)); } /** Heuristic for a freshly launched runner reaching its interactive prompt. */ function dashboardOutputLooksReadyForLaunchPrompt(text, runner) { const tail = dashboardPlainTerminalText(text).slice(-5000); if (dashboardOutputLooksRunnerStartupFailure(tail, runner)) return false; // Antigravity composer-ready signal verified live against `agy` 1.0.1 // (2026-05-24 browser-use smoke against dashboard PTY). Two anchors: // 1. "Antigravity CLI <version>" — identity line present from launch. // 2. "? for shortcuts" — composer hint shown only after the box border // and model row are drawn. // Combined the two patterns are uniquely Antigravity and don't collide with // Claude's `/effort`-keyed composer. const antigravityReady = /Antigravity CLI [0-9]/i.test(tail) && /\?[\s\S]{0,80}for[\s\S]{0,80}shortcuts\b/i.test(tail); if (runner === "antigravity") return antigravityReady; const claudeReady = /\/remote-control is active\b/i.test(tail) && /(^|\n)\s*❯\s*(?:\n|$)/u.test(tail); const claudeComposerReady = /\?[\s\S]{0,80}for[\s\S]{0,80}shortcuts\b/i.test(tail) && /\/effort\b/i.test(tail); const shellReady = /(^|\n)\s*(?:[$#]|>)\s*$/u.test(tail); return claudeReady || claudeComposerReady || shellReady; } /** Heuristic for runner startup failures where launch prompt delivery is unsafe. */ function dashboardOutputLooksRunnerStartupFailure(text, runner) { const tail = dashboardPlainTerminalText(text).slice(-5000); if ((runner === "codex" || /\bcodex\b/i.test(tail)) && /\bError loading configuration:/i.test(tail)) { return true; } return /\bfailed to load Codex config\b/i.test(tail); } /** * Extract a one-line summary of the runner startup error captured in `text` * so we can attach the real cause to the loading-overlay banner instead of * the bare generic message. Returns the trimmed first line of the matched * error, or null if no recognised error pattern is present. */ function dashboardExtractRunnerStartupError(text) { const tail = dashboardPlainTerminalText(text).slice(-5000); const patterns = [ /Error loading configuration:[^\n]+/i, /failed to load Codex config[^\n]*/i, ]; for (const pattern of patterns) { const match = tail.match(pattern); if (match) { const detail = match[0].trim(); return detail.length > 300 ? `${detail.slice(0, 300)}...` : detail; } } return null; } /** Compose the runner-startup error banner: generic prefix + captured detail. */ function dashboardRunnerStartupFailureMessage(text) { const detail = dashboardExtractRunnerStartupError(text); return detail ? `${RUNNER_STARTUP_FAILURE_MESSAGE} ${detail}` : RUNNER_STARTUP_FAILURE_MESSAGE; } /** Heuristic for Claude Code committing a long bracketed paste into the composer. */ function dashboardOutputLooksCommittedPaste(text) { const plain = dashboardPlainTerminalText(text); return (/\[Pasted\s+text(?:\s+#\d+|:)/i.test(plain) || /paste\s+again\s+to\s+expand/i.test(plain)); } /** Return true for xterm-generated protocol replies, not deliberate user input. */ function dashboardTerminalDataLooksProtocolResponse(data) { return (/^\x1b\[(?:I|O)$/.test(data) || /^\x1b\[(?:\?|>)?[0-9;]*c$/.test(data) || /^\x1b\[\d+(?:;\d+)*[Rn]$/.test(data)); } /** Heuristic for a long paste that is still parked in the runner composer. */ function dashboardOutputStillAtCommittedPaste(text) { const tail = dashboardPlainTerminalText(text) .replace(/[ \t]+/g, " ") .trim(); if (!dashboardOutputLooksCommittedPaste(tail)) return false; if (dashboardOutputLooksAwaitingInput(tail)) return false; if (/\b(?:Running|Working)\b|Do you want to proceed\?/i.test(tail)) { return false; } return /paste\s*again\s*to\s*expand[\s\S]{0,160}$/i.test(tail); } /** Decide whether a new output chunk should leave a session waiting. */ function dashboardNextAwaitingInputState(previousAwaiting, previousTail, outputChunk) { const nextTail = (previousTail + outputChunk).slice(-5000); const chunkHasText = dashboardPlainTerminalText(outputChunk).trim().length > 0; if (dashboardOutputLooksAwaitingInput(outputChunk)) return true; const tailStillAwaiting = dashboardOutputLooksAwaitingInput(nextTail); if (!chunkHasText) return previousAwaiting && tailStillAwaiting; if (tailStillAwaiting && (previousAwaiting || dashboardOutputTailEndsWithAwaitingInputStart(previousTail)) && dashboardOutputLooksAwaitingInputContinuation(outputChunk)) { return true; } if (previousAwaiting && tailStillAwaiting && dashboardOutputLooksTransientStatusRedraw(outputChunk)) { return true; } return false; } /** Mutate the Alpine-backed local session and the launch-time reference together. */