Freestyle Docs

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:

  • VM suspend (explicit or idle) — session transitions to Suspended and stays reattachable; the next attach also wakes the VM.
  • VM fork — every child inherits the parent’s non-Exited sessions under the same sessionId, seeded with the parent’s at-fork screen; output diverges per child from there.
  • Program exits — session goes to Exited, stays visible in list() for ~60s, and onExit(code) fires.
  • VM stop / kill — sessions terminate, attached WebSockets are evicted.

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) {
  // Every fork has the parent's session under the same session_id,
  // seeded with the parent's at-fork screen.
  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.

A plain vm.pty.list() (with no vm.user(...)) is the VM owner and sees every session on the VM, including user-scoped ones.

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

// from a Bob-authenticated client, list() does NOT show Alice's session.
const { sessions: bobView } = await vm.user("bob").pty.list();

// from the VM owner, list() shows everything.
const { sessions: ownerView } = await vm.pty.list();

Caps And Limits

  • Sessions per VM: 128. Going over the cap returns SessionCapReached (HTTP 429). Exited sessions count toward the cap until the 60-second retention window expires and the reaper clears them.
  • Scrollback: ~10,000 lines per session, rendered ANSI-aware so the snapshot reproduces colors and cursor position.
  • Signals: SIGINT (Ctrl-C on stdin) and SIGKILL (terminates the session). No other signals are exposed.
  • Errors: pre-upgrade errors (auth, VM not running, session cap) come back as normal HTTP JSON. Errors after the WebSocket is open come back as RFC 6455 close codes (1008 / 1011).
esc