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.

513 lines (512 loc) 23.4 kB
"use strict"; /** * Dashboard terminal paste and launch-prompt lifecycle helpers. */ function dashboardMutateLocalSession(ctx, sessionId, fallback, mutate) { const reactive = ctx.sessions.find((s) => s.id === sessionId); if (reactive) mutate(reactive); if (reactive !== fallback) mutate(fallback); } /** Clear the loading-overlay escalation timers for one terminal session. */ function dashboardClearTerminalLoadingTimers(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs) return; if (refs.loadingSlowTimer) { clearTimeout(refs.loadingSlowTimer); refs.loadingSlowTimer = undefined; } if (refs.loadingRetryTimer) { clearTimeout(refs.loadingRetryTimer); refs.loadingRetryTimer = undefined; } } /** Move one session through the terminal loading-overlay state machine. */ function dashboardSetTerminalLoadingPhase(ctx, sessionId, fallback, phase, error) { if (phase === "ready" || phase === "error") { dashboardClearTerminalLoadingTimers(ctx, sessionId); } dashboardMutateLocalSession(ctx, sessionId, fallback, (target) => { target.loadingPhase = phase; if (phase === "error") { target.loadingError = error ?? "Could not start session."; target.loadingShowRetry = true; } else { target.loadingError = undefined; if (phase === "ready") { target.loadingShowSlowHint = false; target.loadingShowRetry = false; } } }); } /** Arm the slow-start and retry affordances for the loading overlay. */ function dashboardArmTerminalLoadingTimers(ctx, sessionId, fallback) { const refs = ctx._terminalRefs[sessionId]; if (!refs) return; dashboardClearTerminalLoadingTimers(ctx, sessionId); refs.loadingSlowTimer = setTimeout(() => { refs.loadingSlowTimer = undefined; const current = ctx.sessions.find((s) => s.id === sessionId) ?? fallback; if (current.ended || current.loadingPhase === "ready") return; dashboardMutateLocalSession(ctx, sessionId, fallback, (target) => { target.loadingShowSlowHint = true; }); }, TERMINAL_LOADING_SLOW_HINT_MS); refs.loadingRetryTimer = setTimeout(() => { refs.loadingRetryTimer = undefined; const current = ctx.sessions.find((s) => s.id === sessionId) ?? fallback; if (current.ended || current.loadingPhase === "ready") return; dashboardMutateLocalSession(ctx, sessionId, fallback, (target) => { target.loadingShowRetry = true; }); }, TERMINAL_LOADING_RETRY_MS); } /** Mark the loading overlay ready as soon as the PTY sends its first output. */ function dashboardMarkTerminalLoadingReady(ctx, sessionId, fallback, previousTail, output) { if (previousTail.length > 0 || output.length === 0) return; dashboardSetTerminalLoadingPhase(ctx, sessionId, fallback, "ready"); } /** Cancel a pending "awaiting input" reveal for one terminal session. */ function dashboardClearAwaitingInputTimer(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.awaitingInputTimer) return; clearTimeout(refs.awaitingInputTimer); refs.awaitingInputTimer = undefined; } /** Show the waiting badge only after waiting-looking output stays quiet. */ function dashboardScheduleAwaitingInputReveal(ctx, sessionId, fallback) { const refs = ctx._terminalRefs[sessionId]; if (!refs || refs.awaitingInputTimer) return; refs.awaitingInputTimer = setTimeout(() => { refs.awaitingInputTimer = undefined; const reactive = ctx.sessions.find((s) => s.id === sessionId); const current = reactive ?? fallback; if (current.ended) return; if (!dashboardOutputLooksAwaitingInput(current.outputTail ?? "")) return; dashboardMutateLocalSession(ctx, sessionId, fallback, (target) => { target.awaitingInput = true; }); }, AWAITING_INPUT_VISIBLE_DELAY_MS); } /** Cancel a delayed submit for a bracketed paste. */ function dashboardClearPasteSubmitTimer(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.pasteSubmitTimer) return; clearTimeout(refs.pasteSubmitTimer); refs.pasteSubmitTimer = undefined; } /** Cancel all pending delayed submit state for a bracketed paste. */ function dashboardClearPasteSubmitState(ctx, sessionId) { dashboardClearPasteSubmitTimer(ctx, sessionId); const refs = ctx._terminalRefs[sessionId]; if (refs) { refs.pasteSubmitQueue = undefined; refs.pasteSubmitOutputTail = undefined; refs.pasteSubmitAwaitingCommit = false; refs.pasteSubmitFallbackSubmitted = false; } } /** Submit the current terminal composer if the session is still attached. */ function dashboardSendTerminalSubmit(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.ws || refs.ws.readyState !== WebSocket.OPEN) return false; refs.ws.send(JSON.stringify({ type: "input", data: "\r" })); return true; } function dashboardArmPasteSubmitTimer(ctx, sessionId, { delayMs = TERMINAL_PASTE_MARKER_SETTLE_DELAY_MS, retryCount = 0, keepAwaitingCommit = false, retryIfStillCommitted = false, } = {}) { const refs = ctx._terminalRefs[sessionId]; if (!refs) return; dashboardClearPasteSubmitTimer(ctx, sessionId); if (retryCount === 0) refs.pasteSubmitOutputTail = ""; refs.pasteSubmitTimer = setTimeout(() => { const currentRefs = ctx._terminalRefs[sessionId]; if (currentRefs) currentRefs.pasteSubmitTimer = undefined; const submitted = dashboardSubmitPendingPaste(ctx, sessionId, { keepAwaitingCommit, retryIfStillCommitted, }); if (!submitted && retryCount < TERMINAL_PASTE_SUBMIT_MAX_RETRIES) { dashboardArmPasteSubmitTimer(ctx, sessionId, { delayMs: TERMINAL_PASTE_SUBMIT_RETRY_DELAY_MS, retryCount: retryCount + 1, keepAwaitingCommit, retryIfStillCommitted, }); } }, delayMs); } function dashboardReleaseFallbackPasteSubmit(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.pasteSubmitAwaitingCommit) return; refs.pasteSubmitTimer = undefined; refs.pasteSubmitAwaitingCommit = false; refs.pasteSubmitFallbackSubmitted = false; dashboardSendNextQueuedPaste(ctx, sessionId); } function dashboardArmPasteSubmitRetryIfStillCommitted(ctx, sessionId, retryCount = 0) { const refs = ctx._terminalRefs[sessionId]; const target = ctx.sessions.find((session) => session.id === sessionId); if (!refs || typeof target?.outputTail !== "string") { return false; } refs.pasteSubmitTimer = setTimeout(() => { const currentRefs = ctx._terminalRefs[sessionId]; if (currentRefs) currentRefs.pasteSubmitTimer = undefined; const currentTarget = ctx.sessions.find((session) => session.id === sessionId); const currentTail = currentTarget?.outputTail ?? ""; // The stuck-paste heuristic is the loop condition; snapshot equality was // only a single-shot fallback and can fire on marker text that already moved. if (dashboardOutputStillAtCommittedPaste(currentTail)) { dashboardSendTerminalSubmit(ctx, sessionId); const nextRetryCount = retryCount + 1; if (nextRetryCount < TERMINAL_PASTE_SUBMIT_MAX_RETRIES) { dashboardArmPasteSubmitRetryIfStillCommitted(ctx, sessionId, nextRetryCount); return; } } dashboardSendNextQueuedPaste(ctx, sessionId); }, TERMINAL_PASTE_SUBMIT_RETRY_CADENCE_MS); return true; } function dashboardSendNextQueuedPaste(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; const next = refs?.pasteSubmitQueue?.shift(); if (!refs || !next) return; if (refs.pasteSubmitQueue?.length === 0) refs.pasteSubmitQueue = undefined; dashboardSendBracketedPaste(ctx, sessionId, next); } /** Submit a bracketed paste once the runner has had time to commit it. */ function dashboardSubmitPendingPaste(ctx, sessionId, { keepAwaitingCommit = false, retryIfStillCommitted = false, } = {}) { dashboardClearPasteSubmitTimer(ctx, sessionId); const submitted = dashboardSendTerminalSubmit(ctx, sessionId); const refs = ctx._terminalRefs[sessionId]; if (!submitted) return false; if (keepAwaitingCommit && refs?.pasteSubmitAwaitingCommit) { refs.pasteSubmitFallbackSubmitted = true; refs.pasteSubmitTimer = setTimeout(() => { dashboardReleaseFallbackPasteSubmit(ctx, sessionId); }, TERMINAL_PASTE_FALLBACK_RELEASE_DELAY_MS); return true; } if (refs) { refs.pasteSubmitAwaitingCommit = false; refs.pasteSubmitFallbackSubmitted = retryIfStillCommitted; } if (retryIfStillCommitted && dashboardArmPasteSubmitRetryIfStillCommitted(ctx, sessionId)) { return true; } dashboardSendNextQueuedPaste(ctx, sessionId); return submitted; } function dashboardSendBracketedPaste(ctx, sessionId, paste) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.ws || refs.ws.readyState !== WebSocket.OPEN) return; refs.ws.send(JSON.stringify({ type: "input", data: paste.data })); if (paste.shouldDelaySubmit) { const target = ctx.sessions.find((session) => session.id === sessionId); const claudeNoMarkerFallback = target?.runner === "claude"; refs.pasteSubmitAwaitingCommit = true; refs.pasteSubmitFallbackSubmitted = false; dashboardArmPasteSubmitTimer(ctx, sessionId, { delayMs: claudeNoMarkerFallback ? TERMINAL_CLAUDE_PASTE_NO_MARKER_FALLBACK_DELAY_MS : TERMINAL_PASTE_COMMIT_FALLBACK_DELAY_MS, keepAwaitingCommit: !claudeNoMarkerFallback, retryIfStillCommitted: claudeNoMarkerFallback, }); } else if (dashboardSendTerminalSubmit(ctx, sessionId)) { dashboardSendNextQueuedPaste(ctx, sessionId); } else { dashboardArmPasteSubmitTimer(ctx, sessionId, { delayMs: TERMINAL_PASTE_SUBMIT_RETRY_DELAY_MS, retryCount: 1, }); } } function dashboardSendOrQueueBracketedPaste(ctx, sessionId, paste) { const refs = ctx._terminalRefs[sessionId]; if (!refs) return; if (refs.pasteSubmitTimer || refs.pasteSubmitAwaitingCommit) { refs.pasteSubmitQueue = [...(refs.pasteSubmitQueue ?? []), paste]; return; } dashboardSendBracketedPaste(ctx, sessionId, paste); } /** React to runner output while a bracketed paste submit is pending. */ function dashboardHandlePasteSubmitOutput(ctx, sessionId, output) { const refs = ctx._terminalRefs[sessionId]; const target = ctx.sessions.find((session) => session.id === sessionId); const runnerUsesPasteMarker = target?.runner === "claude" || target?.runner === "antigravity"; const hasPendingPaste = refs?.pasteSubmitTimer !== undefined || refs?.pasteSubmitAwaitingCommit === true; if (!refs || (!hasPendingPaste && !runnerUsesPasteMarker)) return; const outputTail = ((refs.pasteSubmitOutputTail ?? "") + output).slice(-2000); refs.pasteSubmitOutputTail = outputTail; const committedPaste = dashboardOutputLooksCommittedPaste(hasPendingPaste ? outputTail : output); if (committedPaste) { const alreadySubmitted = refs.pasteSubmitFallbackSubmitted === true; refs.pasteSubmitAwaitingCommit = false; if (alreadySubmitted) return; refs.pasteSubmitFallbackSubmitted = false; // A "[Pasted text]" marker echoed back when nothing is awaiting submit // means the paste was already submitted (e.g. immediate-submit path for // single-line pastes) or originated outside the dashboard. Don't fire a // spurious extra Enter. if (!hasPendingPaste) return; if (target?.runner === "claude" || target?.runner === "antigravity") { dashboardArmPasteSubmitTimer(ctx, sessionId, { delayMs: TERMINAL_PASTE_MARKER_SETTLE_DELAY_MS, retryIfStillCommitted: true, }); } else { dashboardSubmitPendingPaste(ctx, sessionId); } } } /** Build target context appended to launched preset prompts. */ function dashboardGlobalLaunchContext(ctx, runner, preset) { const controllingWorkspace = dashboardControllingWorkspace(); const mayWrite = preset?.mayWriteFiles === true; const presetPrompt = preset?.prompt.trim() ?? ""; // Launched prompts may suggest learning-loop follow-up, but automatic // durable lesson/footgun/pattern/decision writes require opted-in CLI capture. const writeLine = mayWrite ? "Write behavior: this preset may write only after the prompt or user explicitly approves it." : "Write behavior: default to read-only analysis; do not write files in the selected target unless the user explicitly asks."; const routeLine = preset?.route === "goat-plan" && /^\/goat-plan\b/.test(presetPrompt) ? "goat-plan global mode: honor Step 0 modes; analysis/path-only stay read-only, while File-Write modes may create target .goat-flow/plans when this preset allows writes or the prompt explicitly requests files." : preset?.route === "goat-critique" && /^\/goat-critique\b/.test(presetPrompt) ? "goat-critique global mode: keep gitignored critique logs/artifacts in the controlling workspace; do not write goat-flow logs in the selected target unless the user explicitly makes that target the controlling workspace." : ""; return [ "GOAT Flow target context:", `- Controlling workspace for goat skills/reference files: ${controllingWorkspace}`, `- Selected target project for code evidence: ${ctx.projectPath}`, `- Runner: ${runner}`, "- Target projects do not need goat-flow installed; missing target .goat-flow, skills, hooks, or stale goat-flow files are normal unless this preset audits goat-flow installation.", `- Use target-scoped commands such as git -C ${dashboardShellQuote(ctx.projectPath)} status when inspecting the selected target.`, `- ${writeLine}`, ...(routeLine ? [`- ${routeLine}`] : []), ].join("\n"); } /** Read loaded xterm.js constructors; throws if asset loading did not attach globals. */ function getXtermConstructors() { const Terminal = window.Terminal; const FitAddon = window.FitAddon?.FitAddon; if (!Terminal || !FitAddon) { throw new Error("xterm.js globals unavailable after load"); } return { Terminal, FitAddon }; } /** Send text to a specific terminal session without changing the active tab. */ function dashboardSendToTerminalSession(ctx, sessionId, text, { adapt = true } = {}) { const target = ctx.sessions.find((session) => session.id === sessionId); if (!target) { ctx.showToast("No active terminal session", true); return false; } const refs = ctx._terminalRefs[sessionId]; if (!refs?.ws || refs.ws.readyState !== WebSocket.OPEN) { ctx.showToast("No active terminal session", true); return false; } const prepared = dashboardPreparePasteBody(adapt ? ctx.adaptPrompt(text, target.runner) : text); // Bracketed paste prevents shells and REPLs from treating multi-line prompts as // a stream of independent keystrokes. Claude Code commits long pastes // asynchronously, so submit on its pasted-text echo or fall back after a short // bounded delay for CLIs that do not echo that state. const pasteData = "\x1b[200~" + prepared + "\x1b[201~"; // Claude/Antigravity only compress MULTI-LINE pastes into the "[Pasted text]" // marker we detect to submit fast. Single-line pastes render inline with no // marker, so waiting hits the 15s fallback. Submit those immediately to // match the existing single-line/non-Claude semantics. Verify this // assumption against captured `agy` PTY output before changing it. const isMultiLinePaste = prepared.includes("\n"); const delayedSubmit = (target.runner === "claude" || target.runner === "antigravity") && isMultiLinePaste; dashboardSendOrQueueBracketedPaste(ctx, sessionId, { data: pasteData, shouldDelaySubmit: delayedSubmit, }); dashboardClearAwaitingInputTimer(ctx, sessionId); target.lastInputTime = Date.now(); target.awaitingInput = false; if (ctx.activeSessionId === sessionId && refs.xterm) refs.xterm.focus(); return true; } /** Send text to the active terminal session and focus it. */ function dashboardSendToTerminal(ctx, text, { adapt = true } = {}) { const active = ctx._activeSession; if (!active) { ctx.showToast("No active terminal session", true); return false; } return dashboardSendToTerminalSession(ctx, active.id, text, { adapt }); } /** Cancel the absolute fallback for one pending dashboard launch prompt. */ function dashboardClearLaunchPromptFallbackTimer(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPromptFallbackTimer) return; clearTimeout(refs.launchPromptFallbackTimer); refs.launchPromptFallbackTimer = undefined; } /** Cancel quiet-window delivery for one pending dashboard launch prompt. */ function dashboardClearLaunchPromptQuietTimer(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPromptQuietTimer) return; clearTimeout(refs.launchPromptQuietTimer); refs.launchPromptQuietTimer = undefined; } /** Clear any pending dashboard launch prompt state for one terminal session. */ function dashboardClearLaunchPrompt(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs) return; dashboardClearLaunchPromptFallbackTimer(ctx, sessionId); dashboardClearLaunchPromptQuietTimer(ctx, sessionId); refs.launchPrompt = undefined; refs.launchPromptOutputSeen = false; } /** Send a pending dashboard launch prompt once the terminal is ready. */ function dashboardMaybeSendLaunchPrompt(ctx, sessionId, { force = false } = {}) { const refs = ctx._terminalRefs[sessionId]; const prompt = refs?.launchPrompt; if (!prompt) return false; const target = ctx.sessions.find((session) => session.id === sessionId); if (!target || target.ended) { dashboardClearLaunchPrompt(ctx, sessionId); return false; } if (!refs.ws || refs.ws.readyState !== WebSocket.OPEN) return false; const outputTail = target.outputTail ?? ""; if (dashboardOutputLooksRunnerStartupFailure(outputTail, target.runner)) { dashboardSetTerminalLoadingPhase(ctx, sessionId, target, "error", dashboardRunnerStartupFailureMessage(outputTail)); dashboardClearLaunchPrompt(ctx, sessionId); return false; } const ready = dashboardOutputLooksReadyForLaunchPrompt(outputTail, target.runner); if (!ready && (!force || target.runner === "antigravity")) { return false; } refs.launchPrompt = undefined; dashboardClearLaunchPromptFallbackTimer(ctx, sessionId); dashboardClearLaunchPromptQuietTimer(ctx, sessionId); refs.launchPromptOutputSeen = false; return dashboardSendToTerminalSession(ctx, sessionId, prompt, { adapt: false, }); } /** Arm the conservative fallback used only if the runner produces no output. */ function dashboardArmLaunchPromptNoOutputFallback(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPrompt || refs.launchPromptOutputSeen === true || refs.launchPromptFallbackTimer || !refs.ws || refs.ws.readyState !== WebSocket.OPEN) { return; } refs.launchPromptFallbackTimer = setTimeout(() => { const currentRefs = ctx._terminalRefs[sessionId]; if (currentRefs) currentRefs.launchPromptFallbackTimer = undefined; dashboardMaybeSendLaunchPrompt(ctx, sessionId, { force: true }); }, TERMINAL_LAUNCH_PROMPT_NO_OUTPUT_FALLBACK_DELAY_MS); } /** * Arm a short cap once runner output proves the PTY stream is live. The cap * is unconditional by design: it exists for runners that emit output but never * surface a known readiness marker (custom prompts, alternate CLIs). Gating * the force-send on a readiness check would stall those sessions forever; the * sibling quiet-window path covers the more common "output settles then send" * case. */ function dashboardArmLaunchPromptAfterOutputFallback(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPrompt || refs.launchPromptFallbackTimer) return; refs.launchPromptFallbackTimer = setTimeout(() => { const currentRefs = ctx._terminalRefs[sessionId]; if (currentRefs) currentRefs.launchPromptFallbackTimer = undefined; dashboardMaybeSendLaunchPrompt(ctx, sessionId, { force: true }); }, TERMINAL_LAUNCH_PROMPT_AFTER_OUTPUT_FALLBACK_DELAY_MS); } /** Schedule prompt delivery after runner output has settled. */ function dashboardScheduleLaunchPromptQuietSend(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPrompt || !refs.ws || refs.ws.readyState !== WebSocket.OPEN) return; dashboardClearLaunchPromptQuietTimer(ctx, sessionId); refs.launchPromptQuietTimer = setTimeout(() => { const currentRefs = ctx._terminalRefs[sessionId]; if (currentRefs) currentRefs.launchPromptQuietTimer = undefined; dashboardMaybeSendLaunchPrompt(ctx, sessionId, { force: true }); }, TERMINAL_LAUNCH_PROMPT_QUIET_DELAY_MS); } /** React to a new output chunk while a dashboard launch prompt is pending. */ function dashboardHandleLaunchPromptOutput(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.launchPrompt) return; const firstOutput = refs.launchPromptOutputSeen !== true; refs.launchPromptOutputSeen = true; if (dashboardMaybeSendLaunchPrompt(ctx, sessionId)) return; if (firstOutput) { dashboardClearLaunchPromptFallbackTimer(ctx, sessionId); dashboardArmLaunchPromptAfterOutputFallback(ctx, sessionId); } dashboardScheduleLaunchPromptQuietSend(ctx, sessionId); } /** Send a dashboard launch prompt after the browser terminal is attached. */ function dashboardScheduleLaunchPrompt(ctx, sessionId, prompt) { if (!prompt.trim()) return; dashboardClearLaunchPrompt(ctx, sessionId); const refs = ctx._terminalRefs[sessionId] ?? {}; refs.launchPrompt = prompt; refs.launchPromptOutputSeen = false; ctx._terminalRefs[sessionId] = refs; dashboardArmLaunchPromptNoOutputFallback(ctx, sessionId); dashboardMaybeSendLaunchPrompt(ctx, sessionId); } /** Send a preset prompt to an active session in the current project. */