@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.
487 lines (486 loc) • 21 kB
JavaScript
;
/**
* Dashboard terminal WebSocket connection and session switching helpers.
*/
function dashboardConnectTerminal(ctx, sessionId, wsUrl) {
const session = ctx.sessions.find((s) => s.id === sessionId);
if (!session)
return;
const container = document.getElementById(`gf-terminal-${sessionId}`);
if (!container)
return;
container.innerHTML = "";
let TerminalCtor;
let FitAddonCtor;
try {
const constructors = getXtermConstructors();
TerminalCtor = constructors.Terminal;
FitAddonCtor = constructors.FitAddon;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast(msg, true);
return;
}
const term = new TerminalCtor({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
scrollback: 10000,
theme: {
background: "#0f1729",
foreground: "#f3f4f6",
cursor: "#f3f4f6",
},
});
const fitAddon = new FitAddonCtor();
term.loadAddon(fitAddon);
term.open(container);
term._addonFit = fitAddon;
/** Fit the active xterm instance and report its size to the server. */
const doFit = () => {
if (!container.offsetWidth)
return;
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}));
}
};
// Alpine transitions, font loading, and mobile panel swaps can each land on different
// layout frames. These staggered fits catch the collapsed-first-render case before the
// backend locks in the wrong terminal size.
for (const delay of TERMINAL_INITIAL_FIT_DELAYS_MS) {
setTimeout(doFit, delay);
}
const resizeObserver = new ResizeObserver(() => {
doFit();
});
resizeObserver.observe(container);
/** Handle browser resizes for the active terminal. */
const resizeHandler = () => {
doFit();
};
window.addEventListener("resize", resizeHandler);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}${dashboardTerminalWsPath(wsUrl)}`);
let ageInterval = null;
/** Handle the terminal WebSocket opening. */
ws.onopen = () => {
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.connected = true;
});
dashboardSetTerminalLoadingPhase(ctx, sessionId, session, "loading");
setTimeout(doFit, TERMINAL_REFIT_RETRY_DELAY_MS);
dashboardArmLaunchPromptNoOutputFallback(ctx, sessionId);
dashboardMaybeSendLaunchPrompt(ctx, sessionId);
if (ageInterval)
clearInterval(ageInterval);
ageInterval = setInterval(() => {
if (session.ended) {
if (ageInterval)
clearInterval(ageInterval);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.age = "";
});
return;
}
const elapsed = Math.floor((Date.now() - session.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const hrs = Math.floor(mins / 60);
let age;
if (hrs > 0)
age = `${hrs}h ${mins % 60}m`;
else
age = `${mins}m`;
if (session.lastInputTime && ctx.idleTimeoutMinutes > 0) {
const idleSecs = Math.floor((Date.now() - session.lastInputTime) / 1000);
const idleMins = Math.floor(idleSecs / 60);
const timeout = ctx.idleTimeoutMinutes;
const countdownAt = Math.floor(timeout * 0.97);
const warnAt = Math.floor(timeout * 0.85);
if (idleMins >= countdownAt) {
age = `${mins}m | Timeout in ${Math.max(0, timeout - idleMins)}m`;
}
else if (idleMins >= warnAt) {
age += ` | Idle ${idleMins}m`;
}
}
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.age = age;
});
}, 30000);
if (ctx._terminalRefs[sessionId]) {
ctx._terminalRefs[sessionId].ageInterval = ageInterval;
}
};
/** Handle incoming terminal WebSocket messages. */
ws.onmessage = (event) => {
try {
if (ctx._terminalRefs[sessionId]?.ws !== ws)
return;
if (typeof event.data !== "string")
return;
const msg = readRecord(JSON.parse(event.data), "Terminal message");
const type = readString(msg.type);
if (type === "output" && typeof msg.data === "string") {
const reactive = ctx.sessions.find((s) => s.id === sessionId);
const refs = ctx._terminalRefs[sessionId];
const previousTail = reactive?.outputTail ?? session.outputTail ?? "";
const previousAwaiting = reactive?.awaitingInput === true ||
session.awaitingInput === true ||
refs.awaitingInputTimer !== undefined;
const tail = (previousTail + msg.data).slice(-5000);
const awaitingInput = dashboardNextAwaitingInputState(previousAwaiting, previousTail, msg.data);
const runnerStartupFailed = dashboardOutputLooksRunnerStartupFailure(tail, session.runner);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.outputTail = tail;
});
if (runnerStartupFailed) {
dashboardSetTerminalLoadingPhase(ctx, sessionId, session, "error", dashboardRunnerStartupFailureMessage(tail));
}
else {
dashboardMarkTerminalLoadingReady(ctx, sessionId, session, previousTail, msg.data);
}
dashboardHandlePasteSubmitOutput(ctx, sessionId, msg.data);
if (refs.launchPrompt)
dashboardHandleLaunchPromptOutput(ctx, sessionId);
if (awaitingInput) {
if (reactive?.awaitingInput === true ||
session.awaitingInput === true) {
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.awaitingInput = true;
});
}
else {
dashboardScheduleAwaitingInputReveal(ctx, sessionId, session);
}
}
// Round-6 design: the awaitingInput badge is NEVER cleared by output
// chunks. Five rounds of trying to classify chunks (glyph allowlists,
// tail-end heuristics, OSC-title preservation) failed because runners
// emit continuous spinner / redraw cycles that vary by version and
// accumulate over time, pushing the prompt content out of any bounded
// tail window. The badge is now cleared only by signals that
// unambiguously mean "user moved on":
// 1. `term.onData` - user typed in the dashboard xterm. Xterm
// protocol replies such as focus-in/focus-out and DA responses
// still go to the PTY but do not clear pending paste-submit state.
// 2. Ctrl+V paste from `attachCustomKeyEventHandler` - clipboard
// input goes straight to the WebSocket and bypasses `term.onData`,
// so it shares `markUserInputSent()` with the keystroke path
// 3. `dashboardSendToTerminalSession` - programmatic input from a
// preset launch (line ~943)
// 4. Session lifecycle (exit, terminal-ending error, refresh proves
// gone, detach-as-end) - multiple paths in this handler
// If the runner is answered out-of-band (e.g. via Claude's remote
// control), the badge stays on until session exit. That trade-off is
// explicit and acceptable: a stuck badge after out-of-band answer is
// far less harmful than a badge that never fires at all, which was
// the bug we shipped five rounds trying to fix. See
// .goat-flow/learning-loop/lessons/design-decisions.md (search: `Three rounds of
// the same fix shape`) and .goat-flow/learning-loop/patterns/architecture.md
// (search: `Asymmetric trust - set state from output`).
term.write(msg.data);
}
else if (type === "exit") {
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardClearPasteSubmitState(ctx, sessionId);
dashboardClearLaunchPrompt(ctx, sessionId);
dashboardClearTerminalLoadingTimers(ctx, sessionId);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.ended = true;
target.connected = false;
target.awaitingInput = false;
});
ctx.rememberRecentSession(session);
ctx._forgetSavedSession(sessionId);
if (session.presetId &&
ctx.promptRunStates[session.presetId] === "running") {
ctx.promptRunStates[session.presetId] = "pass";
}
void ctx.updateSessionCount();
}
else if (type === "error" && typeof msg.message === "string") {
const terminalEnded = dashboardTerminalErrorEndsSession(msg.message);
if (session.loadingPhase !== "ready") {
dashboardSetTerminalLoadingPhase(ctx, sessionId, session, "error", msg.message);
}
if (terminalEnded) {
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardClearPasteSubmitState(ctx, sessionId);
dashboardClearLaunchPrompt(ctx, sessionId);
dashboardClearTerminalLoadingTimers(ctx, sessionId);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.ended = true;
target.connected = false;
target.awaitingInput = false;
});
ctx._forgetSavedSession(sessionId);
void ctx.updateSessionCount();
}
term.write(`\r\n\x1b[31m${msg.message}\x1b[0m\r\n`);
}
else if (type === "shutdown") {
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardClearPasteSubmitState(ctx, sessionId);
dashboardClearLaunchPrompt(ctx, sessionId);
dashboardClearTerminalLoadingTimers(ctx, sessionId);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.ended = true;
target.connected = false;
target.awaitingInput = false;
});
}
}
catch {
/* ignore malformed messages */
}
};
/** Handle the terminal WebSocket closing. */
ws.onclose = () => {
if (ctx._terminalRefs[sessionId]?.ws !== ws)
return;
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.connected = false;
});
void ctx.updateSessionCount();
};
/** Handle terminal WebSocket errors. */
ws.onerror = () => {
if (ctx._terminalRefs[sessionId]?.ws !== ws)
return;
if (session.loadingPhase !== "ready") {
dashboardSetTerminalLoadingPhase(ctx, sessionId, session, "error", "WebSocket connection failed");
}
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.connected = false;
});
};
const markUserInputSent = () => {
const lastInputTime = Date.now();
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardClearPasteSubmitState(ctx, sessionId);
dashboardMutateLocalSession(ctx, sessionId, session, (target) => {
target.lastInputTime = lastInputTime;
target.awaitingInput = false;
});
};
term.attachCustomKeyEventHandler((e) => {
if (e.type === "keydown" && e.ctrlKey && e.key === "v") {
e.preventDefault();
navigator.clipboard
.readText()
.then((text) => {
if (text && ws.readyState === WebSocket.OPEN) {
// Bracketed-paste markers tell runners "this is one paste, do not
// submit on internal newlines." Copilot in particular submits on
// every '\n' without these markers, so multi-line clipboard text
// gets fragmented across queries. Claude / Codex / Antigravity
// composers tolerate raw multi-line text but still benefit from
// the explicit marker, so wrap unconditionally.
const prepared = dashboardPreparePasteBody(text);
const data = "\x1b[200~" + prepared + "\x1b[201~";
ws.send(JSON.stringify({ type: "input", data }));
markUserInputSent();
}
})
.catch(() => { });
return false;
}
if (e.type === "keydown" &&
e.ctrlKey &&
e.key === "c" &&
term.hasSelection()) {
navigator.clipboard.writeText(term.getSelection()).catch(() => { });
return false;
}
return true;
});
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ type: "input", data }));
if (!dashboardTerminalDataLooksProtocolResponse(data))
markUserInputSent();
});
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ type: "resize", cols, rows }));
});
/** Tear down dashboard resources before the page unloads. */
const cleanup = () => {
resizeObserver.disconnect();
window.removeEventListener("resize", resizeHandler);
if (ageInterval)
clearInterval(ageInterval);
dashboardClearAwaitingInputTimer(ctx, sessionId);
dashboardClearPasteSubmitState(ctx, sessionId);
dashboardClearLaunchPrompt(ctx, sessionId);
dashboardClearTerminalLoadingTimers(ctx, sessionId);
try {
ws.close();
}
catch {
/* ignore */
}
try {
term.dispose();
}
catch {
/* ignore */
}
};
ctx._terminalRefs[sessionId] = {
...ctx._terminalRefs[sessionId],
ws,
xterm: term,
cleanup,
};
term.focus();
}
/** End a local terminal session and release its browser bindings. */
function dashboardEndSession(ctx, sessionId) {
const session = ctx.sessions.find((s) => s.id === sessionId);
if (!session)
return;
if (session.presetId && ctx.promptRunStates[session.presetId] === "running") {
ctx.promptRunStates[session.presetId] = "pass";
}
if (!session.ended) {
dashboardFetch(`/api/terminal/${sessionId}`, { method: "DELETE" }).catch(() => { });
}
ctx.rememberRecentSession(session);
const refs = ctx._terminalRefs[sessionId];
dashboardClearTerminalLoadingTimers(ctx, sessionId);
if (refs?.cleanup)
refs.cleanup();
Reflect.deleteProperty(ctx._terminalRefs, sessionId);
ctx.sessions = ctx.sessions.filter((s) => s.id !== sessionId);
ctx._forgetSavedSession(sessionId);
if (ctx.activeSessionId === sessionId) {
ctx.activeSessionId = ctx.sessions[0]?.id || null;
}
void ctx.updateSessionCount();
}
/** Exit the active terminal session from the workspace view. */
function dashboardExitTerminal(ctx) {
if (ctx.activeSessionId)
ctx.endSession(ctx.activeSessionId);
}
/** Retry a terminal session that failed or stalled before first PTY output. */
async function dashboardRetryTerminalSession(ctx, sessionId) {
const session = ctx.sessions.find((s) => s.id === sessionId);
if (!session)
return;
const refs = ctx._terminalRefs[sessionId];
const prompt = refs?.retryPrompt ?? refs?.launchPrompt ?? "";
const runner = session.runner;
const promptLabel = refs?.retryPromptLabel ?? session.promptLabel;
const presetId = refs?.retryPresetId ?? session.presetId;
const cwdPath = refs?.retryCwdPath ?? session.cwd;
const targetPath = refs?.retryTargetPath ?? session.targetPath;
dashboardClearTerminalLoadingTimers(ctx, sessionId);
if (refs?.cleanup)
refs.cleanup();
Reflect.deleteProperty(ctx._terminalRefs, sessionId);
ctx.sessions = ctx.sessions.filter((s) => s.id !== sessionId);
if (ctx.activeSessionId === sessionId)
ctx.activeSessionId = null;
await dashboardFetch(`/api/terminal/${sessionId}`, {
method: "DELETE",
}).catch(() => { });
await ctx.launchInTerminal(prompt, runner, {
promptLabel,
presetId,
cwdPath,
targetPath,
});
}
/** Switch the workspace to an existing local terminal session. */
function dashboardSwitchToSession(ctx, sessionId) {
if (!ctx.sessions.find((s) => s.id === sessionId))
return;
ctx.activeSessionId = sessionId;
}
/** Attach the workspace to an existing backend terminal session. */
async function dashboardOpenServerSession(ctx, serverSession) {
const local = ctx.sessions.find((s) => s.id === serverSession.id && !s.ended);
if (local) {
ctx.activeSessionId = local.id;
ctx.activeView = "workspace";
ctx.workspacePanel = "terminal";
if (!local.connected) {
const refs = ctx._terminalRefs[local.id];
dashboardClearTerminalLoadingTimers(ctx, local.id);
if (refs?.cleanup)
refs.cleanup();
ctx._terminalRefs[local.id] = {
...ctx._terminalRefs[local.id],
retryPrompt: "",
retryPromptLabel: local.promptLabel,
retryPresetId: null,
retryCwdPath: local.cwd,
retryTargetPath: local.targetPath,
};
dashboardArmTerminalLoadingTimers(ctx, local.id, local);
const self = ctx;
await self.$nextTick();
ctx.connectTerminal(local.id, `/ws/terminal/${serverSession.id}`);
}
return;
}
ctx.sessions = ctx.sessions.filter((s) => s.id !== serverSession.id);
const self = ctx;
await ctx.loadXterm();
const session = {
id: serverSession.id,
runner: serverSession.runner,
promptLabel: ctx.sessionTitleFor(serverSession),
projectPath: serverSession.projectPath,
cwd: serverSession.cwd,
targetPath: serverSession.targetPath,
startTime: new Date(serverSession.createdAt).getTime(),
lastInputTime: serverSession.lastInputAt || Date.now(),
connected: false,
ended: false,
awaitingInput: false,
outputTail: "",
loadingPhase: "connecting",
loadingShowSlowHint: false,
loadingShowRetry: false,
age: "",
presetId: null,
};
ctx.rememberSessionTitle(session.id, session.promptLabel);
ctx.sessions.push(session);
ctx._terminalRefs[session.id] = {
retryPrompt: "",
retryPromptLabel: session.promptLabel,
retryPresetId: null,
retryCwdPath: session.cwd,
retryTargetPath: session.targetPath,
};
dashboardArmTerminalLoadingTimers(ctx, session.id, session);
ctx.activeSessionId = session.id;
ctx.activeView = "workspace";
ctx.workspacePanel = "terminal";
await self.$nextTick();
ctx.connectTerminal(session.id, `/ws/terminal/${serverSession.id}`);
}
/** Terminate a backend terminal session by ID. */
async function dashboardEndServerSession(ctx, sessionId) {
const local = ctx.sessions.find((s) => s.id === sessionId);
if (local) {
ctx.endSession(sessionId);
}
else {
await dashboardFetch(`/api/terminal/${sessionId}`, {
method: "DELETE",
}).catch(() => { });
}
void ctx.updateSessionCount();
}