diff --git a/docs/inspector.mdx b/docs/inspector.mdx index 2d15971..da3d1c3 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -35,6 +35,7 @@ console.log(url); - Prompt testing - Request/response debugging - Process management (create, stop, kill, delete, view logs) +- Interactive PTY terminal for tty processes - One-shot command execution ## When to use @@ -42,3 +43,9 @@ console.log(url); - Development: validate session behavior quickly - Debugging: inspect raw event payloads - Integration work: compare UI behavior with SDK/API calls + +## Process terminal + +The Inspector includes an embedded Ghostty-based terminal for interactive tty +processes. The UI uses the SDK's high-level `connectProcessTerminal(...)` +wrapper rather than wiring raw websocket frames directly. diff --git a/docs/openapi.json b/docs/openapi.json index d6272b7..2df2bba 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.2.1" + "version": "0.2.2" }, "servers": [ { diff --git a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx b/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx index 13ab86d..858b7c1 100644 --- a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx +++ b/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx @@ -3,35 +3,6 @@ import { FitAddon, Terminal, init } from "ghostty-web"; import { useEffect, useRef, useState } from "react"; import type { SandboxAgent } from "sandbox-agent"; -type ProcessTerminalClientFrame = - | { - type: "input"; - data: string; - encoding?: string; - } - | { - type: "resize"; - cols: number; - rows: number; - } - | { - type: "close"; - }; - -type ProcessTerminalServerFrame = - | { - type: "ready"; - processId: string; - } - | { - type: "exit"; - exitCode?: number | null; - } - | { - type: "error"; - message: string; - }; - type ConnectionState = "connecting" | "ready" | "closed" | "error"; const terminalTheme = { @@ -76,21 +47,19 @@ const GhosttyTerminal = ({ let cancelled = false; let terminal: Terminal | null = null; let fitAddon: FitAddon | null = null; - let socket: WebSocket | null = null; + let session: ReturnType | null = null; let resizeRaf = 0; let removeDataListener: { dispose(): void } | null = null; let removeResizeListener: { dispose(): void } | null = null; const syncSize = () => { - if (!terminal || !socket || socket.readyState !== WebSocket.OPEN) { + if (!terminal || !session) { return; } - const frame: ProcessTerminalClientFrame = { - type: "resize", + session.resize({ cols: terminal.cols, rows: terminal.rows, - }; - socket.send(JSON.stringify(frame)); + }); }; const connect = async () => { @@ -118,14 +87,7 @@ const GhosttyTerminal = ({ terminal.focus(); removeDataListener = terminal.onData((data) => { - if (!socket || socket.readyState !== WebSocket.OPEN) { - return; - } - const frame: ProcessTerminalClientFrame = { - type: "input", - data, - }; - socket.send(JSON.stringify(frame)); + session?.sendInput(data); }); removeResizeListener = terminal.onResize(() => { @@ -135,64 +97,50 @@ const GhosttyTerminal = ({ resizeRaf = window.requestAnimationFrame(syncSize); }); - const nextSocket = client.connectProcessTerminalWebSocket(processId); - nextSocket.binaryType = "arraybuffer"; - socket = nextSocket; + const nextSession = client.connectProcessTerminal(processId); + session = nextSession; - nextSocket.addEventListener("message", async (event) => { + nextSession.onReady((frame) => { if (cancelled) { return; } - - if (typeof event.data === "string") { - const frame = parseServerFrame(event.data); - if (!frame) { - setConnectionState("error"); - setStatusMessage("Received invalid terminal control frame."); - return; - } - - if (frame.type === "ready") { - setConnectionState("ready"); - setStatusMessage("Connected"); - syncSize(); - return; - } - - if (frame.type === "exit") { - setConnectionState("closed"); - setExitCode(frame.exitCode ?? null); - setStatusMessage( - frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` - ); - onExit?.(); - return; - } - - setConnectionState("error"); - setStatusMessage(frame.message); - return; - } - - if (!terminal) { - return; - } - - const bytes = await decodeBinaryFrame(event.data); - if (!cancelled) { - terminal.write(bytes); + if (frame.type === "ready") { + setConnectionState("ready"); + setStatusMessage("Connected"); + syncSize(); } }); - nextSocket.addEventListener("error", () => { + nextSession.onData((bytes) => { + if (cancelled || !terminal) { + return; + } + terminal.write(bytes); + }); + + nextSession.onExit((frame) => { + if (cancelled) { + return; + } + if (frame.type === "exit") { + setConnectionState("closed"); + setExitCode(frame.exitCode ?? null); + setStatusMessage( + frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` + ); + onExit?.(); + } + }); + + nextSession.onError((error) => { if (cancelled) { return; } setConnectionState("error"); - setStatusMessage("Terminal websocket connection failed."); + setStatusMessage(error instanceof Error ? error.message : error.message); }); - nextSocket.addEventListener("close", () => { + nextSession.onClose(() => { if (cancelled) { return; } @@ -217,11 +165,7 @@ const GhosttyTerminal = ({ } removeDataListener?.dispose(); removeResizeListener?.dispose(); - if (socket?.readyState === WebSocket.OPEN) { - const frame: ProcessTerminalClientFrame = { type: "close" }; - socket.send(JSON.stringify(frame)); - } - socket?.close(); + session?.close(); terminal?.dispose(); }; }, [client, onExit, processId]); @@ -253,58 +197,4 @@ const GhosttyTerminal = ({ ); }; -function parseServerFrame(payload: string): ProcessTerminalServerFrame | null { - try { - const parsed = JSON.parse(payload) as unknown; - if (!parsed || typeof parsed !== "object" || !("type" in parsed)) { - return null; - } - - if ( - parsed.type === "ready" && - "processId" in parsed && - typeof parsed.processId === "string" - ) { - return parsed as ProcessTerminalServerFrame; - } - - if ( - parsed.type === "exit" && - (!("exitCode" in parsed) || - parsed.exitCode == null || - typeof parsed.exitCode === "number") - ) { - return parsed as ProcessTerminalServerFrame; - } - - if ( - parsed.type === "error" && - "message" in parsed && - typeof parsed.message === "string" - ) { - return parsed as ProcessTerminalServerFrame; - } - } catch { - return null; - } - - return null; -} - -async function decodeBinaryFrame(data: unknown): Promise { - if (data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - - if (ArrayBuffer.isView(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); - } - - if (typeof Blob !== "undefined" && data instanceof Blob) { - return new Uint8Array(await data.arrayBuffer()); - } - - throw new Error(`Unsupported terminal payload: ${String(data)}`); -} - export default GhosttyTerminal; diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 4f23bab..e4bbb2d 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -56,6 +56,8 @@ import { type ProcessRunRequest, type ProcessRunResponse, type ProcessSignalQuery, + type ProcessTerminalClientFrame, + type ProcessTerminalServerFrame, type ProcessTerminalResizeRequest, type ProcessTerminalResizeResponse, type SessionEvent, @@ -63,6 +65,10 @@ import { type SessionRecord, type SkillsConfig, type SkillsConfigQuery, + type TerminalErrorStatus, + type TerminalExitStatus, + type TerminalReadyStatus, + type TerminalResizePayload, } from "./types.ts"; const API_PREFIX = "/v1"; @@ -158,6 +164,8 @@ export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketU WebSocket?: typeof WebSocket; } +export type ProcessTerminalSessionOptions = ProcessTerminalConnectOptions; + export class SandboxAgentError extends Error { readonly status: number; readonly problem?: ProblemDetails; @@ -586,6 +594,169 @@ export class LiveAcpConnection { } } +export class ProcessTerminalSession { + readonly socket: WebSocket; + readonly closed: Promise; + + private readonly readyListeners = new Set<(status: TerminalReadyStatus) => void>(); + private readonly dataListeners = new Set<(data: Uint8Array) => void>(); + private readonly exitListeners = new Set<(status: TerminalExitStatus) => void>(); + private readonly errorListeners = new Set<(error: TerminalErrorStatus | Error) => void>(); + private readonly closeListeners = new Set<() => void>(); + + private closeSignalSent = false; + private closedResolve!: () => void; + + constructor(socket: WebSocket) { + this.socket = socket; + this.socket.binaryType = "arraybuffer"; + this.closed = new Promise((resolve) => { + this.closedResolve = resolve; + }); + + this.socket.addEventListener("message", (event) => { + void this.handleMessage(event.data); + }); + this.socket.addEventListener("error", () => { + this.emitError(new Error("Terminal websocket connection failed.")); + }); + this.socket.addEventListener("close", () => { + this.closedResolve(); + for (const listener of this.closeListeners) { + listener(); + } + }); + } + + onReady(listener: (status: TerminalReadyStatus) => void): () => void { + this.readyListeners.add(listener); + return () => { + this.readyListeners.delete(listener); + }; + } + + onData(listener: (data: Uint8Array) => void): () => void { + this.dataListeners.add(listener); + return () => { + this.dataListeners.delete(listener); + }; + } + + onExit(listener: (status: TerminalExitStatus) => void): () => void { + this.exitListeners.add(listener); + return () => { + this.exitListeners.delete(listener); + }; + } + + onError(listener: (error: TerminalErrorStatus | Error) => void): () => void { + this.errorListeners.add(listener); + return () => { + this.errorListeners.delete(listener); + }; + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + sendInput(data: string | ArrayBuffer | ArrayBufferView): void { + const payload = encodeTerminalInput(data); + this.sendFrame({ + type: "input", + data: payload.data, + encoding: payload.encoding, + }); + } + + resize(payload: TerminalResizePayload): void { + this.sendFrame({ + type: "resize", + cols: payload.cols, + rows: payload.rows, + }); + } + + close(): void { + if (this.socket.readyState === WebSocket.CONNECTING) { + this.socket.addEventListener( + "open", + () => { + this.close(); + }, + { once: true }, + ); + return; + } + + if (this.socket.readyState === WebSocket.OPEN) { + if (!this.closeSignalSent) { + this.closeSignalSent = true; + this.sendFrame({ type: "close" }); + } + this.socket.close(); + return; + } + + if (this.socket.readyState !== WebSocket.CLOSED) { + this.socket.close(); + } + } + + private async handleMessage(data: unknown): Promise { + try { + if (typeof data === "string") { + const frame = parseProcessTerminalServerFrame(data); + if (!frame) { + this.emitError(new Error("Received invalid terminal control frame.")); + return; + } + + if (frame.type === "ready") { + for (const listener of this.readyListeners) { + listener(frame); + } + return; + } + + if (frame.type === "exit") { + for (const listener of this.exitListeners) { + listener(frame); + } + return; + } + + this.emitError(frame); + return; + } + + const bytes = await decodeTerminalBytes(data); + for (const listener of this.dataListeners) { + listener(bytes); + } + } catch (error) { + this.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + private sendFrame(frame: ProcessTerminalClientFrame): void { + if (this.socket.readyState !== WebSocket.OPEN) { + return; + } + + this.socket.send(JSON.stringify(frame)); + } + + private emitError(error: TerminalErrorStatus | Error): void { + for (const listener of this.errorListeners) { + listener(error); + } + } +} + export class SandboxAgent { private readonly baseUrl: string; private readonly token?: string; @@ -1344,6 +1515,13 @@ export class SandboxAgent { ); } + connectProcessTerminal( + id: string, + options: ProcessTerminalSessionOptions = {}, + ): ProcessTerminalSession { + return new ProcessTerminalSession(this.connectProcessTerminalWebSocket(id, options)); + } + private async getLiveConnection(agent: string): Promise { await this.awaitHealthy(); @@ -1757,6 +1935,91 @@ type NormalizedHealthWaitOptions = | { enabled: false; timeoutMs?: undefined; signal?: undefined } | { enabled: true; timeoutMs?: number; signal?: AbortSignal }; +function parseProcessTerminalServerFrame(payload: string): ProcessTerminalServerFrame | null { + try { + const parsed = JSON.parse(payload) as unknown; + if (!isRecord(parsed) || typeof parsed.type !== "string") { + return null; + } + + if (parsed.type === "ready" && typeof parsed.processId === "string") { + return parsed as ProcessTerminalServerFrame; + } + + if ( + parsed.type === "exit" && + (parsed.exitCode === undefined || + parsed.exitCode === null || + typeof parsed.exitCode === "number") + ) { + return parsed as ProcessTerminalServerFrame; + } + + if (parsed.type === "error" && typeof parsed.message === "string") { + return parsed as ProcessTerminalServerFrame; + } + } catch { + return null; + } + + return null; +} + +function encodeTerminalInput( + data: string | ArrayBuffer | ArrayBufferView, +): { data: string; encoding?: "base64" } { + if (typeof data === "string") { + return { data }; + } + + const bytes = encodeTerminalBytes(data); + return { + data: bytesToBase64(bytes), + encoding: "base64", + }; +} + +function encodeTerminalBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); +} + +async function decodeTerminalBytes(data: unknown): Promise { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); + } + + if (typeof Blob !== "undefined" && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + + throw new Error(`Unsupported terminal frame payload: ${String(data)}`); +} + +function bytesToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + + if (typeof btoa === "function") { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); + } + + throw new Error("Base64 encoding is not available in this environment."); +} + /** * Auto-select and call `authenticate` based on the agent's advertised auth methods. * Prefers env-var-based methods that the server process already has configured. diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index cf25645..6b5c9a4 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,5 +1,6 @@ export { LiveAcpConnection, + ProcessTerminalSession, SandboxAgent, SandboxAgentError, Session, @@ -19,6 +20,7 @@ export type { ProcessLogListener, ProcessLogSubscription, ProcessTerminalConnectOptions, + ProcessTerminalSessionOptions, ProcessTerminalWebSocketUrlOptions, SandboxAgentConnectOptions, SandboxAgentStartOptions, @@ -88,6 +90,11 @@ export type { SessionRecord, SkillsConfig, SkillsConfigQuery, + TerminalErrorStatus, + TerminalExitStatus, + TerminalReadyStatus, + TerminalResizePayload, + TerminalStatusMessage, } from "./types.ts"; export type { diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 3c0674b..54efab4 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -89,6 +89,16 @@ export type ProcessTerminalServerFrame = | ProcessTerminalExitFrame | ProcessTerminalErrorFrame; +export type TerminalReadyStatus = ProcessTerminalReadyFrame; +export type TerminalExitStatus = ProcessTerminalExitFrame; +export type TerminalErrorStatus = ProcessTerminalErrorFrame; +export type TerminalStatusMessage = ProcessTerminalServerFrame; + +export interface TerminalResizePayload { + cols: number; + rows: number; +} + export interface SessionRecord { id: string; agent: string; diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 7243aa8..1da387b 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -136,22 +136,6 @@ function writeTarChecksum(buffer: Buffer, checksum: number): void { buffer[155] = 0x20; } -function decodeSocketPayload(data: unknown): string { - if (typeof data === "string") { - return data; - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); - } - if (typeof Blob !== "undefined" && data instanceof Blob) { - throw new Error("Blob socket payloads are not supported in this test"); - } - throw new Error(`Unsupported socket payload type: ${typeof data}`); -} - function decodeProcessLogData(data: string, encoding: string): string { if (encoding === "base64") { return Buffer.from(data, "base64").toString("utf8"); @@ -816,37 +800,46 @@ describe("Integration: TypeScript SDK flat session API", () => { const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id); expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true); - const ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id, { + const session = sdk.connectProcessTerminal(ttyProcess.id, { WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, }); - ws.binaryType = "arraybuffer"; + const readyFrames: string[] = []; + const ttyOutput: string[] = []; + const exitFrames: Array = []; + const terminalErrors: string[] = []; + let closeCount = 0; - const socketTextFrames: string[] = []; - const socketBinaryFrames: string[] = []; - ws.addEventListener("message", (event) => { - if (typeof event.data === "string") { - socketTextFrames.push(event.data); - return; - } - socketBinaryFrames.push(decodeSocketPayload(event.data)); + session.onReady((frame) => { + readyFrames.push(frame.processId); + }); + session.onData((bytes) => { + ttyOutput.push(Buffer.from(bytes).toString("utf8")); + }); + session.onExit((frame) => { + exitFrames.push(frame.exitCode); + }); + session.onError((error) => { + terminalErrors.push(error instanceof Error ? error.message : error.message); + }); + session.onClose(() => { + closeCount += 1; }); - await waitFor(() => { - const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"')); - return ready; - }); + await waitFor(() => readyFrames[0]); - ws.send(JSON.stringify({ - type: "input", - data: "hello tty\n", - })); + session.sendInput("hello tty\n"); await waitFor(() => { - const joined = socketBinaryFrames.join(""); + const joined = ttyOutput.join(""); return joined.includes("hello tty") ? joined : undefined; }); - ws.close(); + session.close(); + await session.closed; + expect(closeCount).toBeGreaterThan(0); + expect(exitFrames).toHaveLength(0); + expect(terminalErrors).toEqual([]); + await waitForAsync(async () => { const processInfo = await sdk.getProcess(ttyProcess.id); return processInfo.status === "running" ? processInfo : undefined;