@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.
578 lines (577 loc) • 22.8 kB
JavaScript
;
/**
* Dashboard terminal availability, xterm asset loading, and session launch helpers.
*/
async function dashboardSendToProjectTarget(ctx, prompt, target) {
if (target.projectPath !== ctx.projectPath) {
ctx.showToast("Target session is not in this project", true);
return;
}
if (ctx.isSessionBoundLocally(target.id)) {
ctx.activeSessionId = target.id;
ctx.activeView = "workspace";
ctx.workspacePanel = "terminal";
}
else {
await ctx.openServerSession(target);
}
const prepared = ctx.adaptPrompt(prompt, target.runner);
/** Retry a project-scoped send until the target terminal is ready. */
const deliver = async (attempts) => {
const refs = ctx._terminalRefs[ctx.activeSessionId ?? ""];
if (refs?.ws && refs.ws.readyState === WebSocket.OPEN) {
ctx.sendToTerminal(prepared, { adapt: false });
return;
}
if (attempts > 20) {
ctx.showToast("Could not connect to terminal", true);
return;
}
await new Promise((r) => setTimeout(r, 100));
return deliver(attempts + 1);
};
await deliver(0);
}
/** Refresh terminal feature availability from the health endpoint. */
async function dashboardCheckTerminalAvailable(ctx) {
try {
const res = await dashboardFetch("/api/health");
if (res.ok) {
const payload = readRecord(await res.json(), "Health response");
ctx.availableRunners = Array.isArray(payload.availableRunners)
? payload.availableRunners
.map((runner) => readRunnerId(runner))
.filter((runner) => runner !== null)
: [];
ctx.terminalAvailable =
payload.nodePtyAvailable === true && ctx.availableRunners.length > 0;
ctx.platformHint =
typeof payload.platformHint === "string" ? payload.platformHint : null;
ctx.idleTimeoutMinutes =
typeof payload.idleTimeoutMinutes === "number"
? payload.idleTimeoutMinutes
: 480;
const [firstRunner] = ctx.availableRunners;
if (firstRunner)
ctx.activeRunner = firstRunner;
if (ctx.terminalAvailable &&
(ctx.activeView === "workspace" || ctx.activeView === "setup")) {
void dashboardWarmXterm(ctx);
}
}
}
catch {
ctx.terminalAvailable = false;
}
void ctx.updateSessionCount();
}
/** Refresh terminal session state from the server. */
async function dashboardUpdateSessionCount(ctx) {
if (sessionRefreshPromise)
return sessionRefreshPromise;
sessionRefreshPromise = new Promise((resolve) => {
sessionRefreshDebounceTimer = setTimeout(() => {
void dashboardUpdateSessionCountImpl(ctx).finally(() => {
sessionRefreshPromise = null;
sessionRefreshDebounceTimer = null;
resolve();
});
}, SESSION_REFRESH_DEBOUNCE_MS);
});
return sessionRefreshPromise;
}
async function dashboardUpdateSessionCountImpl(ctx) {
try {
const res = await dashboardFetch("/api/terminal/sessions");
const payload = readRecord(await res.json(), "Terminal sessions response");
ctx.terminalSessionCount =
typeof payload.activeCount === "number" ? payload.activeCount : 0;
if (typeof payload.maxSessions === "number") {
ctx.serverMaxSessions = payload.maxSessions;
}
ctx.serverSessions = Array.isArray(payload.sessions)
? payload.sessions
.map((session) => readServerSessionInfo(session))
.filter((session) => session !== null)
.map((session) => ({
...session,
projectName: ctx.displayNameFor(session.projectPath),
}))
: [];
const activeIds = new Set(ctx.serverSessions.map((session) => session.id));
for (const session of ctx.sessions) {
if (session.ended || session.connected || activeIds.has(session.id)) {
continue;
}
dashboardClearAwaitingInputTimer(ctx, session.id);
dashboardClearTerminalLoadingTimers(ctx, session.id);
session.ended = true;
session.awaitingInput = false;
ctx._forgetSavedSession(session.id);
}
}
catch {
/* ignore */
}
}
/** Clear non-active (terminated/starting) terminal sessions, preserving running ones. */
async function dashboardEndAllSessions(ctx) {
try {
const res = await dashboardFetch("/api/terminal/sessions");
const payload = readRecord(await res.json(), "Terminal sessions response");
const sessions = Array.isArray(payload.sessions)
? payload.sessions
.map((session) => readServerSessionInfo(session))
.filter((session) => session !== null)
: [];
const inactive = sessions.filter((session) => session.status !== "active");
const activeIds = new Set(sessions
.filter((session) => session.status === "active")
.map((session) => session.id));
const localRecentCount = ctx.recentTerminalSessions.length;
for (const session of inactive) {
await dashboardFetch(`/api/terminal/${session.id}`, {
method: "DELETE",
});
}
ctx.recentTerminalSessions = [];
const keptRefs = {};
for (const id of Object.keys(ctx._terminalRefs)) {
if (activeIds.has(id)) {
const active = ctx._terminalRefs[id];
if (active)
keptRefs[id] = active;
}
else {
const refs = ctx._terminalRefs[id];
dashboardClearTerminalLoadingTimers(ctx, id);
if (refs?.cleanup)
refs.cleanup();
}
}
ctx._terminalRefs = keptRefs;
const keptProjects = {};
for (const key of Object.keys(ctx._projectSessions)) {
const kept = (ctx._projectSessions[key] ?? []).filter((s) => activeIds.has(s.sessionId));
if (kept.length > 0)
keptProjects[key] = kept;
}
ctx._projectSessions = keptProjects;
for (const key of Object.keys(ctx._projectActiveSession)) {
const activeSessionForProject = ctx._projectActiveSession[key];
if (activeSessionForProject && !activeIds.has(activeSessionForProject)) {
const projectSessions = keptProjects[key];
if (projectSessions?.[0]) {
ctx._projectActiveSession[key] = projectSessions[0].sessionId;
}
else {
Reflect.deleteProperty(ctx._projectActiveSession, key);
}
}
}
ctx.sessions = ctx.sessions.filter((s) => activeIds.has(s.id));
if (ctx.activeSessionId && !activeIds.has(ctx.activeSessionId)) {
ctx.activeSessionId = null;
}
for (const [presetId, state] of Object.entries(ctx.promptRunStates)) {
if (state === "running" &&
!ctx.sessions.some((s) => s.presetId === presetId)) {
ctx.promptRunStates[presetId] = "pass";
}
}
await ctx.updateSessionCount();
const count = inactive.length + localRecentCount;
ctx.showToast(count > 0
? `Cleared ${count} recent session${count !== 1 ? "s" : ""}`
: "No recent sessions to clear");
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast("Failed to clear sessions: " + msg, true);
}
}
/** Load the xterm.js globals on demand before any terminal view is rendered. */
function removeXtermAssetElements() {
document
.querySelector('link[rel="stylesheet"][href="/assets/xterm.css"]')
?.remove();
document.querySelector('script[src="/assets/xterm.js"]')?.remove();
document.querySelector('script[src="/assets/addon-fit.js"]')?.remove();
}
function waitForAssetElement(element, label) {
if (element.dataset["loaded"] === "true")
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} load timeout`));
}, 5000);
const cleanup = () => {
clearTimeout(timer);
element.removeEventListener("load", onLoad);
element.removeEventListener("error", onError);
};
const onLoad = () => {
cleanup();
element.dataset["loaded"] = "true";
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`${label} load failed`));
};
element.addEventListener("load", onLoad, { once: true });
element.addEventListener("error", onError, { once: true });
});
}
/** Load xterm CSS once, reusing an existing tag so reconnects do not duplicate assets. */
async function loadXtermStylesheet() {
const existing = document.querySelector('link[rel="stylesheet"][href="/assets/xterm.css"]');
if (existing) {
await waitForAssetElement(existing, "xterm.css");
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/xterm.css";
const loaded = waitForAssetElement(link, "xterm.css");
document.head.appendChild(link);
await loaded;
}
/** Load one xterm script asset, waiting for existing tags when another tab started first. */
async function loadXtermScript(src, label) {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
await waitForAssetElement(existing, label);
return;
}
const script = document.createElement("script");
script.src = src;
const loaded = waitForAssetElement(script, label);
document.head.appendChild(script);
await loaded;
}
async function dashboardLoadXterm(ctx) {
if (ctx._xtermLoaded)
return;
if (!xtermLoadPromise) {
xtermLoadPromise = (async () => {
await loadXtermStylesheet();
// The fit addon patches the global Terminal constructor, so xterm itself
// has to finish loading before the addon script is appended.
await loadXtermScript("/assets/xterm.js", "xterm.js");
await loadXtermScript("/assets/addon-fit.js", "fit addon");
getXtermConstructors();
})();
}
try {
await xtermLoadPromise;
ctx._xtermLoaded = true;
}
catch (err) {
xtermLoadPromise = null;
removeXtermAssetElements();
throw err;
}
}
/** Warm xterm.js in the background so the first launch does less visible work. */
async function dashboardWarmXterm(ctx) {
if (!ctx.terminalAvailable || ctx._xtermLoaded)
return;
try {
await ctx.loadXterm();
}
catch {
// Surface load failures only on explicit launch.
}
}
/** Launch a preset prompt in the selected runner. */
async function dashboardLaunchPreset(ctx, prompt, runner, label, options = {}) {
if (ctx.launching)
return;
const preset = (options.presetId
? (ctx.allPresets.find((p) => p.id === options.presetId) ?? null)
: null) ??
ctx.allPresets.find((p) => ctx.adaptPrompt(p.prompt) === ctx.adaptPrompt(prompt) ||
(typeof label === "string" && p.name === label)) ??
null;
const promptLabel = label || preset?.name || "Custom prompt";
const presetId = preset?.id || options.presetId || null;
const runnerResolved = runner || ctx.activeRunner;
if (presetId)
ctx.promptRunStates[presetId] = "running";
let adapted = ctx.adaptPrompt(prompt, runnerResolved);
adapted +=
"\n\n" + dashboardGlobalLaunchContext(ctx, runnerResolved, preset ?? null);
if (ctx.userRole === "investigator") {
adapted =
"You are in investigator mode. Read-only - investigate, plan, and review only. Do NOT make any code changes.\n\n" +
adapted;
}
else if (ctx.userRole === "tester") {
adapted =
"You are in tester mode. Test-focused - generate test plans, verify coverage, run QA analysis. Do NOT make code changes beyond test files.\n\n" +
adapted;
}
await ctx.launchInTerminal(adapted, runnerResolved, {
promptLabel,
presetId,
cwdPath: options.cwdPath ?? null,
targetPath: options.targetPath ?? ctx.projectPath,
});
}
/** Drop a session id from every project's saved list, pruning empty entries. */
function dashboardForgetSavedSession(ctx, sessionId) {
for (const [path, list] of Object.entries(ctx._projectSessions)) {
const filtered = list.filter((sv) => sv.sessionId !== sessionId);
if (filtered.length === 0) {
Reflect.deleteProperty(ctx._projectSessions, path);
}
else if (filtered.length !== list.length) {
ctx._projectSessions[path] = filtered;
}
if (ctx._projectActiveSession[path] === sessionId) {
const first = filtered[0];
if (first) {
ctx._projectActiveSession[path] = first.sessionId;
}
else {
Reflect.deleteProperty(ctx._projectActiveSession, path);
}
}
}
}
/** Detach the current browser terminal while preserving reconnect metadata. */
function dashboardDetachTerminal(ctx, forProjectPath) {
ctx._detaching = true;
const savePath = forProjectPath || ctx.projectPath;
const toSave = ctx.sessions
.filter((s) => s.projectPath === savePath && !s.ended)
.map((s) => ({
sessionId: s.id,
startTime: s.startTime,
prompt: s.promptLabel,
agent: s.runner,
cwd: s.cwd,
targetPath: s.targetPath,
}));
if (toSave.length > 0) {
ctx._projectSessions[savePath] = toSave;
const activeId = ctx.activeSessionId;
const fallback = toSave[0];
if (activeId && toSave.some((s) => s.sessionId === activeId)) {
ctx._projectActiveSession[savePath] = activeId;
}
else if (fallback) {
ctx._projectActiveSession[savePath] = fallback.sessionId;
}
}
else {
Reflect.deleteProperty(ctx._projectSessions, savePath);
Reflect.deleteProperty(ctx._projectActiveSession, savePath);
}
for (const id of Object.keys(ctx._terminalRefs)) {
const refs = ctx._terminalRefs[id];
dashboardClearTerminalLoadingTimers(ctx, id);
if (refs?.cleanup)
refs.cleanup();
}
ctx._terminalRefs = {};
ctx.sessions = [];
ctx.activeSessionId = null;
ctx.promptRunStates = {};
ctx._detaching = false;
}
/** Reconnect the workspace to every saved backend session for this project. */
async function dashboardReconnectTerminal(ctx) {
const savedList = ctx._projectSessions[ctx.projectPath];
const aliveMap = new Map();
try {
const res = await dashboardFetch("/api/terminal/sessions");
const payload = readRecord(await res.json(), "Terminal sessions response");
if (Array.isArray(payload.sessions)) {
for (const raw of payload.sessions) {
const session = readServerSessionInfo(raw);
if (session)
aliveMap.set(session.id, session);
}
}
}
catch {
Reflect.deleteProperty(ctx._projectSessions, ctx.projectPath);
Reflect.deleteProperty(ctx._projectActiveSession, ctx.projectPath);
return false;
}
if (!savedList || savedList.length === 0) {
const activeId = ctx.activeSessionId;
const activeServerSession = activeId ? aliveMap.get(activeId) : null;
if (!activeServerSession)
return false;
await ctx.openServerSession(activeServerSession);
return true;
}
const liveSaved = savedList.filter((sv) => aliveMap.has(sv.sessionId));
if (liveSaved.length === 0) {
Reflect.deleteProperty(ctx._projectSessions, ctx.projectPath);
Reflect.deleteProperty(ctx._projectActiveSession, ctx.projectPath);
return false;
}
ctx._projectSessions[ctx.projectPath] = liveSaved;
const self = ctx;
await ctx.loadXterm();
for (const saved of liveSaved) {
const alive = aliveMap.get(saved.sessionId);
if (!alive)
continue;
const session = {
id: saved.sessionId,
runner: saved.agent,
promptLabel: saved.prompt,
projectPath: alive.projectPath,
cwd: alive.cwd || saved.cwd || alive.projectPath,
targetPath: alive.targetPath || saved.targetPath || alive.projectPath,
startTime: saved.startTime,
lastInputTime: alive.lastInputAt,
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);
}
const savedActiveId = ctx._projectActiveSession[ctx.projectPath];
const first = liveSaved[0];
ctx.activeSessionId =
savedActiveId && liveSaved.some((s) => s.sessionId === savedActiveId)
? savedActiveId
: (first?.sessionId ?? null);
ctx.activeView = "workspace";
ctx.workspacePanel = "terminal";
await self.$nextTick();
for (const saved of liveSaved) {
ctx.connectTerminal(saved.sessionId, `/ws/terminal/${saved.sessionId}`);
}
void ctx.updateSessionCount();
return true;
}
/** Create a new backend terminal session and open it in the workspace. */
async function dashboardLaunchInTerminal(ctx, prompt, runner = "claude", { promptLabel = null, presetId = null, cwdPath = null, targetPath = null, } = {}) {
if (Math.max(ctx.sessions.length, ctx.serverSessions.length) >=
ctx.serverMaxSessions) {
ctx.showMaxSessionsModal = true;
return;
}
let createdSessionId = null;
ctx.launching = true;
try {
const self = ctx;
const selectedTargetPath = targetPath || ctx.projectPath;
const controllingCwd = cwdPath || selectedTargetPath;
let xtermPromise;
try {
xtermPromise = ctx.loadXterm().then(() => ({ ok: true }), (error) => ({ ok: false, error }));
}
catch (error) {
xtermPromise = Promise.resolve({ ok: false, error });
}
const res = await dashboardFetch("/api/terminal/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "",
projectPath: controllingCwd,
targetPath: selectedTargetPath,
runner,
}),
});
const payload = readRecord(await res.json(), "Terminal create response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
const id = readString(payload.id);
const wsUrl = readString(payload.wsUrl);
if (!id || !wsUrl) {
throw new Error("Terminal create response returned an invalid payload");
}
const session = {
id,
runner,
promptLabel: promptLabel || "Custom prompt",
projectPath: selectedTargetPath,
cwd: controllingCwd,
targetPath: selectedTargetPath,
startTime: Date.now(),
lastInputTime: Date.now(),
connected: false,
ended: false,
awaitingInput: false,
outputTail: "",
loadingPhase: "connecting",
loadingShowSlowHint: false,
loadingShowRetry: false,
age: "",
presetId,
};
createdSessionId = session.id;
ctx.rememberSessionTitle(session.id, session.promptLabel);
ctx.sessions.push(session);
ctx._terminalRefs[session.id] = {
retryPrompt: prompt,
retryPromptLabel: session.promptLabel,
retryPresetId: presetId,
retryCwdPath: controllingCwd,
retryTargetPath: selectedTargetPath,
};
dashboardArmTerminalLoadingTimers(ctx, session.id, session);
ctx.activeSessionId = session.id;
ctx.activeView = "workspace";
ctx.workspacePanel = "terminal";
await self.$nextTick();
const xtermResult = await xtermPromise;
if (!xtermResult.ok)
throw xtermResult.error;
ctx.connectTerminal(session.id, wsUrl);
dashboardScheduleLaunchPrompt(ctx, session.id, prompt);
void ctx.updateSessionCount();
}
catch (err) {
if (createdSessionId) {
const failedSessionId = createdSessionId;
const refs = ctx._terminalRefs[failedSessionId];
dashboardClearTerminalLoadingTimers(ctx, failedSessionId);
if (refs?.cleanup)
refs.cleanup();
Reflect.deleteProperty(ctx._terminalRefs, failedSessionId);
ctx.sessions = ctx.sessions.filter((s) => s.id !== failedSessionId);
if (ctx.activeSessionId === failedSessionId) {
ctx.activeSessionId = ctx.sessions[0]?.id || null;
}
dashboardFetch(`/api/terminal/${failedSessionId}`, {
method: "DELETE",
}).catch(() => { });
void ctx.updateSessionCount();
}
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("Maximum") || msg.includes("concurrent")) {
ctx.showMaxSessionsModal = true;
}
else {
ctx.showToast(msg, true);
}
}
ctx.launching = false;
}
/** Bind a browser xterm instance to a backend PTY session. */