@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
JavaScript
"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. */