+
+ {statusMessage}
+ {exitCode != null ? exit={exitCode} : null}
+
+
{
+ hostRef.current?.querySelector("textarea")?.focus();
+ }}
+ />
+
+ );
+};
diff --git a/sdks/react/src/index.ts b/sdks/react/src/index.ts
new file mode 100644
index 0000000..5af657d
--- /dev/null
+++ b/sdks/react/src/index.ts
@@ -0,0 +1,6 @@
+export { ProcessTerminal } from "./ProcessTerminal.tsx";
+
+export type {
+ ProcessTerminalClient,
+ ProcessTerminalProps,
+} from "./ProcessTerminal.tsx";
diff --git a/sdks/react/tsconfig.json b/sdks/react/tsconfig.json
new file mode 100644
index 0000000..1ee4e6b
--- /dev/null
+++ b/sdks/react/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/sdks/react/tsup.config.ts b/sdks/react/tsup.config.ts
new file mode 100644
index 0000000..70a8b7e
--- /dev/null
+++ b/sdks/react/tsup.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["esm"],
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ target: "es2022",
+});
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index 4f23bab..9db9762 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,173 @@ 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 === WS_READY_STATE_CONNECTING) {
+ this.socket.addEventListener(
+ "open",
+ () => {
+ this.close();
+ },
+ { once: true },
+ );
+ return;
+ }
+
+ if (this.socket.readyState === WS_READY_STATE_OPEN) {
+ if (!this.closeSignalSent) {
+ this.closeSignalSent = true;
+ this.sendFrame({ type: "close" });
+ }
+ this.socket.close();
+ return;
+ }
+
+ if (this.socket.readyState !== WS_READY_STATE_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 !== WS_READY_STATE_OPEN) {
+ return;
+ }
+
+ this.socket.send(JSON.stringify(frame));
+ }
+
+ private emitError(error: TerminalErrorStatus | Error): void {
+ for (const listener of this.errorListeners) {
+ listener(error);
+ }
+ }
+}
+
+const WS_READY_STATE_CONNECTING = 0;
+const WS_READY_STATE_OPEN = 1;
+const WS_READY_STATE_CLOSED = 3;
+
export class SandboxAgent {
private readonly baseUrl: string;
private readonly token?: string;
@@ -1344,6 +1519,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 +1939,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;
diff --git a/server/packages/sandbox-agent/src/process_runtime.rs b/server/packages/sandbox-agent/src/process_runtime.rs
index 4a895ec..cd1bedd 100644
--- a/server/packages/sandbox-agent/src/process_runtime.rs
+++ b/server/packages/sandbox-agent/src/process_runtime.rs
@@ -331,7 +331,10 @@ impl ProcessRuntime {
}
timeout_ms = timeout_ms.min(config.max_run_timeout_ms);
- let max_output_bytes = spec.max_output_bytes.unwrap_or(config.max_output_bytes);
+ let max_output_bytes = spec
+ .max_output_bytes
+ .unwrap_or(config.max_output_bytes)
+ .min(config.max_output_bytes);
let mut cmd = Command::new(&spec.command);
cmd.args(&spec.args)
@@ -343,6 +346,9 @@ impl ProcessRuntime {
cmd.current_dir(cwd);
}
+ if !spec.env.contains_key("TERM") {
+ cmd.env("TERM", "xterm-256color");
+ }
for (key, value) in &spec.env {
cmd.env(key, value);
}
diff --git a/server/packages/sandbox-agent/tests/v1_api.rs b/server/packages/sandbox-agent/tests/v1_api.rs
index 9e5cc3b..fa572e6 100644
--- a/server/packages/sandbox-agent/tests/v1_api.rs
+++ b/server/packages/sandbox-agent/tests/v1_api.rs
@@ -135,6 +135,38 @@ fn write_executable(path: &Path, script: &str) {
}
}
+fn write_fake_npm(path: &Path) {
+ write_executable(
+ path,
+ r#"#!/usr/bin/env sh
+set -e
+prefix=""
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ install|--no-audit|--no-fund)
+ shift
+ ;;
+ --prefix)
+ prefix="$2"
+ shift 2
+ ;;
+ *)
+ shift
+ ;;
+ esac
+done
+[ -n "$prefix" ] || exit 1
+mkdir -p "$prefix/node_modules/.bin"
+for bin in claude-code-acp codex-acp amp-acp pi-acp cursor-agent-acp; do
+ echo '#!/usr/bin/env sh' > "$prefix/node_modules/.bin/$bin"
+ echo 'exit 0' >> "$prefix/node_modules/.bin/$bin"
+ chmod +x "$prefix/node_modules/.bin/$bin"
+done
+exit 0
+"#,
+ );
+}
+
fn serve_registry_once(document: Value) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server");
let address = listener.local_addr().expect("registry address");
diff --git a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
index 1728f8b..dc352ca 100644
--- a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
+++ b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
@@ -182,10 +182,7 @@ async fn lazy_install_runs_on_first_bootstrap() {
.expect("create agent processes dir");
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
- write_executable(
- &install_path.join("bin").join("npx"),
- "#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
- );
+ write_fake_npm(&install_path.join("bin").join("npm"));
});
let original_path = std::env::var_os("PATH").unwrap_or_default();