Skip to content

Pseudo Terminal (PTY)

View as Markdown

Daytona provides pseudo terminal (PTY) capabilities through the process module in sandboxes. A PTY is a process started with the PTY kind: it runs under a virtual terminal so programs behave as if connected to a real terminal, with terminal size, line discipline, and interactive prompts.

PTY processes are exposed through a sandbox’s PTY namespace and share the handle API of other processes, described in process execution.

PTY sessions are used when a program expects a real terminal.

  • Interactive development: REPLs, debuggers, and editors that read keystrokes and redraw the screen.
  • Terminals in web applications: a browser terminal connects to a PTY session, sends keystrokes, and resizes the session with the window.
  • Builds and tests: compilers, test runners, and deploy tools that show progress or colors only when attached to a terminal.
  • System administration: setup tools and package managers that ask questions before they continue.
  • Interrupts: a long-running process receives Ctrl+C through the terminal and stops cleanly.
  • Reconnects: a client that reconnects replays the output from a cursor and sees what it missed.
  • Shared sessions: a named session is reachable by its name from another client. Clients attached to one session at the same time share its shell, keystrokes, and output.
  • Terminal agents: an agent works in one interactive shell that keeps its working directory and variables between commands. It reads the output as a terminal shows it, including escape sequences.

Start a PTY session. Without a command, the session runs an interactive shell: the login shell of the user it runs as, or the first of bash, zsh, and sh found in the image. With a command, the session runs that command in the terminal instead. The terminal starts at 80 columns by 24 rows unless you set a size, and you can resize it later.

The SDKs and the CLI keep an exited session’s record and output until deleted.

from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
# Without a command the PTY runs an interactive shell; the terminal defaults to 80x24
handle = sandbox.pty.start()
print(handle.id)
from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
handle = sandbox.pty.start("python3 -i", cols=100, rows=40)
ParameterTypeDescription
commandstrSets the command to run in the PTY.
colsintSets the number of terminal columns. The value must be at least 1. The default is 80.
rowsintSets the number of terminal rows. The value must be at least 1. The default is 24.
cwdstrSets the working directory of the command.
envDict[str, str]Sets environment variables for the command.
namestrSets the session name. See name PTY session.
  • The Python pty.start() always opens stdin.
  • The Python pty.start() keeps the record and output until the process is deleted.

Name a PTY session. A name aliases the session ID, so another client can reach the session without knowing its ID. The name belongs to the session only while it runs: when the session exits, the name is released for reuse, and the session stays reachable by its ID only.

from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
handle = sandbox.pty.start(name="dev-shell")
# Names are unique among live processes and resolve wherever an ID is accepted
info = sandbox.process.get("dev-shell")
  • Names use letters, digits, ., _, and -, up to 128 characters.
  • The names entrypoint and dockerd are reserved for system processes.

Configure the shell, the user, and the timeout of a PTY session. A shell that cannot be found or a user that does not exist fails the start. When the timeout elapses, the session is killed and reports that it timed out. The Python SDK does not set these options for PTY sessions.

import { Daytona } from '@daytona/sdk'
const daytona = new Daytona()
const sandbox = await daytona.v2.create()
const handle = await sandbox.pty.start(undefined, {
shell: '/bin/bash', // default: the user's login shell, then bash, zsh, sh
user: 'root', // run as another user
executionTimeout: 600_000, // SIGKILL after 10 minutes; wait() reports reason 'timed_out'
})
ParameterTypeDescription
shellstringSets the shell, as a path or a name looked up on PATH.
userstringSets the user the session runs as. The user must exist in /etc/passwd of the sandbox.

Connect to an existing PTY session by its ID. Connecting creates a handle without contacting the sandbox; the handle sends input to the session, reads its output, resizes it, and stops it. A live connection streams output produced after it opens; to see earlier output, replay it from a cursor.

handle = sandbox.pty.connect(process_id)
handle.stdin('echo "Reconnected"\n')
# The Python SDK has no WebSocket attach; handle.attach() raises
# UnsupportedOperationError. Use stdin() and stream_logs() instead.

List PTY sessions, including retained exited ones; check each session’s state.

from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
processes = sandbox.pty.list()
for info in processes:
print(f"PTY: {info.id} ({info.state})")
if info.terminal:
print(f" Size: {info.terminal.cols}x{info.terminal.rows}")
print(f" Working directory: {info.cwd}")
info = handle.get()
print(f"PTY: {info.id}")
print(f"State: {info.state}")
print(f"PID: {info.pid}")
if info.terminal:
print(f"Size: {info.terminal.cols}x{info.terminal.rows}")
print(f"Working directory: {info.cwd}")
if info.state == "terminal":
print(f"Exit code: {info.exit_code}")

Stop a PTY session and remove it. Stopping sends a signal to the session’s processes; removing deletes the session record and its output. Removing a session that is still running kills it first.

# Sends SIGTERM; delete() removes the process record and logs
handle.kill()
handle.delete()
ParameterTypeDescription
signalSignalNameSets the signal that kill() sends: TERM, KILL, INT, HUP, QUIT, USR1, or USR2. The default is "TERM".
escalate_afterfloatSends escalate_to if the session is still running after this delay. The delay is in seconds, at most 600.
escalate_toSignalNameSets the signal sent after escalate_after. The default is "KILL".
  • On a running session, delete() sends SIGKILL.
  • delete() waits up to 3 seconds for the exit, then removes the session.

Delete a PTY session in one step: a running session gets a termination signal, then the session record and its output are removed. Deleting a non-PTY process fails and leaves it running.

sandbox.pty.delete(handle.id)

Resize the terminal of a running PTY session. The program in the terminal receives a window size change signal and can redraw at the new size. The session record reports the new size.

handle.resize(cols=150, rows=40)
  • cols and rows must be at least 1.
  • You can resize only PTY sessions.

Send input to a PTY session. Input reaches the terminal as if typed, so end each command with a newline. Control keys are bytes: send Ctrl+C as 0x03 and Ctrl+D as 0x04. A terminal has no separate end of input; to end input, send Ctrl+D.

handle.stdin("ls -la\n")
ParameterTypeDescription
dataUnion[str, bytes]Sets the input to send, as str or bytes. Bytes are sent base64 encoded. This parameter is required.

Read one page of PTY output. A page holds up to 1,000 frames by default. To read past the first page, continue from a cursor as shown in replay output from a cursor.

page = handle.logs()
for frame in page.items:
print(frame.data, end="")
ParameterTypeDescription
limitintSets the page size. The daemon clamps the page size to 1 to 5000. The default is 1000.
encodingLogEncodingSets the output encoding. In text encoding, bytes that are not valid UTF-8 are replaced. Pass "base64" to read raw bytes. The default is "text".

Replay PTY output from a cursor. Every output frame carries a cursor, and a read with a cursor returns the frames after it, so a client that saved the last cursor it processed can resume without gaps or duplicates. Retention can evict old output; a cursor that points into evicted output fails, and the error carries the oldest cursor still available. See log retention.

# Every frame carries a cursor; reading resumes strictly after it. An empty page means the read has caught up
cursor = last_cursor
while True:
page = handle.logs(cursor=cursor)
if not page.items:
break
for frame in page.items:
print(frame.data, end="")
cursor = frame.cursor

Wait for a PTY session to exit. The result holds the exit code, the ending signal if any, and a reason: exited, killed by a signal, timed out, failed to start, or lost. If the wait time elapses first, the result reports a timeout with no exit code, and the session keeps running.

result = handle.wait(timeout=30)
print(result.reason, result.exit_code)
ParameterTypeDescription
timeoutfloatSets how long to wait, in seconds. Omit it or pass 0 to wait until the session exits.

Run several commands in one interactive shell. The shell keeps its state between commands, such as the working directory and variables. Read output while you send input; the output stream ends after the shell exits and all of its output is delivered.

import threading
from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
handle = sandbox.pty.start()
# Print output in the background
def pump():
for frame in handle.stream_logs():
print(frame.data, end="")
reader = threading.Thread(target=pump, daemon=True)
reader.start()
for command in [
"pwd",
"ls -la",
"echo 'Hello from PTY'",
"python3 -c 'print(2 + 2)'",
"exit",
]:
handle.stdin(command + "\n")
result = handle.wait()
reader.join(timeout=5)
print(f"\nShell exited with code: {result.exit_code}")
handle.delete()

Interrupt a running command from the terminal. Ctrl+C sent as input goes through the terminal, which interrupts the foreground command; the shell itself keeps running. Signaling the session instead targets the whole session, including the shell. See kill PTY session.

import time
from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
handle = sandbox.pty.start()
handle.stdin("for i in $(seq 1 100); do echo \"Line $i\"; sleep 1; done\n")
time.sleep(5)
handle.stdin("\x03") # Ctrl+C
handle.stdin("exit\n")
result = handle.wait()
for frame in handle.logs().items:
print(frame.data, end="")
print(f"\nExit code: {result.exit_code}")
handle.delete()

Remove a PTY session when you no longer need it. The SDKs keep an exited session’s record and output until it is deleted, so delete it in a final block to remove it even when a step fails.

from daytona import Daytona
daytona = Daytona()
sandbox = daytona.v2.create()
handle = sandbox.pty.start()
try:
handle.stdin("echo hello\n")
handle.stdin("exit\n")
handle.wait(timeout=10)
finally:
handle.delete()