Interactive terminal
Open an interactive login shell in a sandbox and drive it with raw bytes instead of one command at a time: send it keystrokes, resize it, reconnect to it from another process, and kill it.
Use a terminal when the session has to keep state between keystrokes: a REPL, a program that asks questions, or a terminal you render in a browser. For one-off commands use Run commands instead.
Every example reads IMPELLO_API_KEY from the environment. Make a key at
dashboard.impello.ai/keys.
Create a terminal
sandbox.pty.create() starts /bin/bash -i -l attached to a pseudo-terminal
of the size you give it and returns a handle. The handle's pid identifies the
terminal in every later call.
import { Sandbox } from '@impello/sdk'
const sandbox = await Sandbox.create('base')
const decoder = new TextDecoder()
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: (data) => {
process.stdout.write(decoder.decode(data, { stream: true }))
},
})
console.log(terminal.pid)Output arrives as bytes, not text. In TypeScript onData receives a
Uint8Array for every chunk the shell writes. Decode it with a streaming
TextDecoder so a multi-byte character split across two chunks survives. The
sync Python create() takes no callback. Read the output by iterating the
handle, or pass on_pty to wait(), both shown below. Python hands you the
raw bytes either way, so decode them with an incremental decoder for the same
reason.
The shell starts with TERM=xterm-256color, LANG=C.UTF-8 and
LC_ALL=C.UTF-8. Anything you pass in envs overrides those.
Options
pty.create() takes these options:
| TypeScript | Python | Default | What it does |
|---|---|---|---|
cols, rows | size: PtySize | required | Terminal size in characters. PtySize takes rows then cols. |
onData | on_data (async only) | required in TypeScript and async Python | Called with each chunk of output. The sync Python create() has no callback. |
timeoutMs | timeout | 60000 ms / 60 s | How long the terminal may run. When it passes, the sandbox kills the shell. 0 removes the limit. |
user | user | the template's user | Linux user the shell runs as. The base template's user is user. |
envs | envs | {} plus TERM, LANG and LC_ALL | Environment variables for the shell. |
cwd | cwd | that user's home directory | Working directory the shell starts in. |
To keep a terminal open longer, raise timeoutMs (timeout in Python, in
seconds). timeoutMs: 0 in TypeScript and timeout=0 in Python remove the
limit.
The sandbox's own timeout also ends the terminal. When it runs out, the
terminal goes with everything else inside the sandbox: killed, or paused if the
sandbox's lifecycle says so. See Timeouts.
Send input
Send keystrokes as bytes with pty.sendInput() in TypeScript or
pty.send_stdin() in Python. The names differ by more than casing. Include the
newline, because the shell does not run a line until it sees one.
const encoder = new TextEncoder()
await sandbox.pty.sendInput(terminal.pid, encoder.encode('echo hello\n'))
await sandbox.pty.sendInput(terminal.pid, encoder.encode('exit\n'))
await terminal.wait()
await sandbox.kill()Control characters are bytes like any other: send \x03 for Ctrl-C and \x04
for Ctrl-D. wait() returns once the shell exits, and a non-zero exit code
raises CommandExitError (CommandExitException in Python). See
Errors.
The handle is a CommandHandle, the same class a background command returns,
but a terminal has no separate stdin. handle.sendStdin() and
handle.closeStdin() (handle.send_stdin() and handle.close_stdin() in
Python) throw SandboxError (SandboxException in Python) on
it. Always send input through sandbox.pty.
Resize the terminal
Call resize() whenever the window the terminal is drawn in changes size, so
full-screen programs redraw at the right width.
await sandbox.pty.resize(terminal.pid, { cols: 120, rows: 40 })Reconnect to a terminal
A terminal keeps running after the handle that created it goes away, until its
timeoutMs passes. You can pick it up from another process or after a network
drop. connect() takes the pid and returns a fresh handle.
sandbox.commands.list() returns every running command, terminal and template
start command as a ProcessInfo. A terminal is the entry whose args start
with -i: commands run as /bin/bash -l -c <command>, terminals as
/bin/bash -i -l.
import { Sandbox } from '@impello/sdk'
const sandbox = await Sandbox.connect(process.env.SANDBOX_ID as string)
const decoder = new TextDecoder()
const running = await sandbox.commands.list()
const shell = running.find((p) => p.args[0] === '-i')
if (shell) {
const terminal = await sandbox.pty.connect(shell.pid, {
onData: (data) => {
process.stdout.write(decoder.decode(data, { stream: true }))
},
timeoutMs: 60 * 60 * 1000,
})
await terminal.wait()
}The sync Python handle is iterable and yields (stdout, stderr, pty) tuples
with one field set. For a terminal it is always the third.
connect() takes timeoutMs (timeout) with the same 60 second default as
create(). On connect() it bounds only how long the new handle listens. When
it passes, wait() throws TimeoutError and the shell keeps running. The
examples above pass their own bound so they stay attached for longer. Call
handle.disconnect() to stop receiving output without killing the shell.
Kill a terminal
pty.kill() sends SIGKILL to the shell. It returns true when the terminal
was killed and false when no process has that pid.
await sandbox.pty.kill(terminal.pid)handle.kill() does the same for the handle's own pid. Killing the sandbox
kills every terminal in it.
Async Python
AsyncSandbox has the same pty module with every method awaited. The async
create() and connect() take an on_data callback, because there is no sync
iterator to read from, and wait() takes no callbacks.
import asyncio
import codecs
from impello import AsyncSandbox, PtySize
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
async def main():
sandbox = await AsyncSandbox.create("base")
terminal = await sandbox.pty.create(
PtySize(rows=24, cols=80),
on_data=lambda data: print(decoder.decode(data), end=""),
)
await sandbox.pty.send_stdin(terminal.pid, b"echo hello\n")
await sandbox.pty.send_stdin(terminal.pid, b"exit\n")
await terminal.wait()
await sandbox.kill()
asyncio.run(main())
Next steps
- Filesystem to read and write files in the sandbox.
- Run commands for one-off commands.