Terminals: unify on a shared resilient-terminal core + deploy script for agent images
ci / gates (push) Failing after 13s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

- frontend/.../terminal/core.ts: `useResilientTerminal` owns the xterm lifecycle
  (init, fit, debounced resize, reconnect, refit-on-visible) with a pluggable
  transport. Two connectors: `wsConnector` (agent terminal → container PTY over WS)
  and `nodeWebrtcConnector` (node terminal → host PTY, direct WebRTC DataChannel
  with WS-relay fallback, input buffered during the race). The agent terminal now
  gets the node terminal's robust reconnect for free; both share one xterm setup.
- TerminalApp (agent) + NodeTerminalApp (node) reduced to thin wrappers over the
  core — net ~330 lines of duplicated transport/reconnect/xterm code removed.
- scripts/deploy.sh: the deploys were manual, so the locally-built agent images
  (agent-base/browser/terminal — not in any registry) were never shipped and 404'd
  on provision. The script always (re)builds + loads them onto gw-04 AND every
  fleet node, with a skip-if-identical guard so unchanged images aren't re-transferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 07:55:43 -07:00
co-authored by Claude Opus 4.8
parent a3bb16d838
commit 1072ec159a
4 changed files with 486 additions and 371 deletions
@@ -13,6 +13,7 @@ import type { Agent } from "@/lib/api/schemas";
import { panelParsers } from "@/lib/url/panel-params";
import { useSubHeader } from "./AppShell";
import { useResilientTerminal, wsConnector } from "./terminal/core";
import "@xterm/xterm/css/xterm.css";
@@ -261,8 +262,9 @@ export default function TerminalApp({ agent }: { agent: Agent }) {
);
}
/** One tab: an xterm bridged to a tmux session over a WebSocket. Kept mounted
* (hidden when inactive) so its session + processes keep running. */
/** One tab: an xterm bridged to a tmux session over a WebSocket (the shared
* resilient-terminal core). Kept mounted (hidden when inactive) so its session +
* processes keep running. */
function TerminalTab({
agent,
session,
@@ -272,157 +274,32 @@ function TerminalTab({
session: string;
active: boolean;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Latest `active` for callbacks without re-running the connect effect.
// Latest `active` for the core's callbacks without re-running the connect.
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
});
useEffect(() => {
let disposed = false;
let ro: ResizeObserver | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
const term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
allowProposedApi: true,
scrollback: 5000,
theme: {
background: "#0b0b0e",
foreground: "#e6e6ea",
cursor: "#ff8a7a",
cursorAccent: "#0b0b0e",
selectionBackground: "rgba(255,138,122,.28)",
black: "#1c1c22",
red: "#ff6f61",
green: "#5fd08a",
yellow: "#e8b465",
blue: "#5ec8d8",
magenta: "#c98af0",
cyan: "#6fd0c0",
white: "#cfcfd5",
brightBlack: "#5a5a62",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
termRef.current = term;
fitRef.current = fit;
// Only fit when actually visible (a hidden tab has zero size).
if (hostRef.current.clientWidth > 0) {
try {
fit.fit();
} catch {
/* not laid out */
}
}
const encoder = new TextEncoder();
term.onData((d) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
});
const sendResize = () => {
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit.fit();
} catch {
/* ignore */
}
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ro = new ResizeObserver(() => sendResize());
ro.observe(hostRef.current);
const connect = async () => {
if (disposed) return;
try {
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
const { ticket } = (await res.json()) as { ticket: string };
if (disposed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
ticket,
)}&session=${encodeURIComponent(session)}`,
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
sendResize();
if (activeRef.current) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") term.write(ev.data);
else term.write(new Uint8Array(ev.data as ArrayBuffer));
};
ws.onerror = () => ws.close();
ws.onclose = () => {
wsRef.current = null;
if (disposed) return;
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
retry = setTimeout(connect, 1500);
};
} catch (e) {
if (disposed) return;
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
retry = setTimeout(connect, 2500);
}
};
void connect();
})();
return () => {
disposed = true;
if (retry) clearTimeout(retry);
ro?.disconnect();
wsRef.current?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
}, [agent.id, session]);
const { hostRef, refit } = useResilientTerminal(
{
connect: wsConnector(async () => {
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
if (!res.ok) return null;
const { ticket } = (await res.json()) as { ticket: string };
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(ticket)}&session=${encodeURIComponent(session)}`;
}),
visible: () => activeRef.current,
autoFocus: true,
},
[agent.id, session],
);
// Re-fit + focus when this tab becomes visible (it may have had zero size).
useEffect(() => {
if (!active) return;
const id = setTimeout(() => {
const term = termRef.current;
const fit = fitRef.current;
const ws = wsRef.current;
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit?.fit();
} catch {
/* ignore */
}
if (term && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
term?.focus();
}, 40);
const id = setTimeout(refit, 40);
return () => clearTimeout(id);
}, [active]);
}, [active, refit]);
return (
<div