PTY Sessions - Freestyle Docs

PTY Sessions

Open persistent interactive shells on a VM that survive WebSocket disconnects, VM suspends, and forks.

A PTY (pseudo-terminal) session is a long-lived interactive shell that lives inside the VM and that you can attach to, detach from, and reattach to over a WebSocket. Sessions survive client disconnects, VM suspends, and VM forks, letting agents drive interactive programs (REPLs, editors, package managers, debuggers) without re-spawning every command.

Use a PTY when vm.exec() is too coarse: the program needs an interactive terminal, you want to send keystrokes mid-run, you need scrollback across reconnects, or you’re running a background server (a dev server, a file watcher) whose output you want to read later.

Open A Session

const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes),
  onExit: (code) => console.log("program exited with", code),
});

session.write("echo hello\n");
console.log(session.sessionId);

With no exec, the agent spawns the user’s login shell (or /bin/sh). The session is backed by a real PTY, so the shell is interactive — prompt, line editing, job control — and an explicit exec runs interactively too (a REPL like python3, a TUI like htop). cols and rows default to 80 x 24. Keep session.sessionId if you might reattach later.

Reattach Across Disconnects

Calling .detach() (or losing the network) leaves the program running in the VM. Reconnect with attach({ sessionId }) and you get the current screen replayed plus live output from that point.

session.detach();

// later, or from a different process / machine
const rebound = await vm.pty.attach({
  sessionId: session.sessionId,
  onData: (bytes) => process.stdout.write(bytes),
});

detach() closes only the local handle; the server-side session keeps running. The original session object is unusable after detach — write / resize / signal throw, telling you to vm.pty.attach({ sessionId }) to get a fresh handle.

Send Input, Resize, Signal

session.write("ls -la\n");
session.write(new Uint8Array([0x03])); // Ctrl-C as raw byte
session.resize({ cols: 200, rows: 60 });
session.signal("SIGINT");

write() accepts string (UTF-8 encoded) or Uint8Array. signal() accepts SIGINT (delivered as Ctrl-C on stdin) and SIGKILL (terminates the session’s root process).

Behavior Across VM Lifecycle

PTY sessions are first-class with respect to the VM lifecycle:

The key invariant: an attached PTY does NOT hold the VM open or block a lifecycle operation. If the VM is suspended (idle or explicit) or torn down, attached clients are evicted with a clean WebSocket close; the session itself stays reattachable as long as the VM is reachable again.

If you need a VM to stay running while a PTY session is in use, configure idleTimeoutSeconds: null (or a long value) on vm.start(). The platform’s idle monitor measures only VM network-interface traffic, so PTY input — and even vm.exec() — does not reset the timer. To keep the VM alive without disabling idle-suspend entirely, the program in the VM has to generate outbound network traffic (an HTTP request, etc.) on its own.

Auto-Reconnect

The SDK reconnects transparently on transient WebSocket drops, including the eviction triggered when a VM suspends. The reattach also wakes the VM if needed, so callers rarely have to think about transient hiccups.

Defaults: 5 attempts, exponential backoff from 500ms doubling to 8s. Writes called during a reconnect attempt are queued and flushed once the new WebSocket opens. onClose fires only when the session is terminally closed — program exited, caller called detach(), or reconnect attempts were exhausted.

const session = await vm.pty.open({
  exec: "/bin/sh",
  reconnect: { maxAttempts: 10, baseDelayMs: 200 },
  onReconnecting: (attempt, max) => console.log(`reconnect ${attempt}/${max}`),
  onReconnect: () => console.log("reconnected"),
});

// or disable entirely:
const noRetry = await vm.pty.open({ exec: "/bin/sh", reconnect: false });

With reconnect: false the original session is dead the moment its WebSocket closes — capture sessionId ahead of time and call vm.pty.attach({ sessionId }) to get a new session object.

Inheritance On Fork

const session = await vm.pty.open({ exec: "/bin/sh" });
const parentSessionId = session.sessionId;
session.write("echo parent\n");

const { forks } = await vm.fork({ count: 2 });
for (const { vm: child } of forks) {
  const childSession = await child.pty.attach({ sessionId: parentSessionId });
  childSession.write("echo from-fork\n");  // diverges from parent here
}

Writes on a fork’s session go to that fork’s process only; output never crosses between siblings or parent. Sessions that had already Exited in the parent are not inherited.

List And Close

const { sessions } = await vm.pty.list();
for (const s of sessions) {
  console.log(s.sessionId, s.exec, s.running, s.suspended, s.attachedCount);
}

await vm.pty.close({ sessionId });

Running, Suspended, and recently-Exited sessions stay in list() for ~60 seconds after exit so reattach can read the final exit code. close() kills the underlying program (SIGKILL semantics) and removes the session.

Multi-User Isolation

If the VM is configured with linux users, PTY sessions are scoped per user. A session opened with vm.user("alice").pty.open() runs as alice. A vm.user("bob") caller does not see Alice’s sessions in list() and gets 403 SessionUserMismatch if it tries to attach or close one.

const aliceSession = await vm.user("alice").pty.open({ exec: "/bin/sh" });
const { sessions: bobView } = await vm.user("bob").pty.list();
const { sessions: ownerView } = await vm.pty.list();

Caps And Limits