Interactive Shell
Open a PTY-backed shell and work with the session wrapper safely.
Opening a Shell
Use SSHConnection.openShell(pseudoTerminalRequest:environment:) to create an interactive session:
import Traversio
@available(macOS 26.0, iOS 26.0, *)
func runInteractiveSession(configuration: SSHClientConfiguration) async throws {
try await SSHClient.withConnection(configuration: configuration) { connection in
let session = try await connection.openShell()
try await session.write("pwd\n")
try await session.write("whoami\n")
try await session.write("exit\n")
for try await event in session.events {
switch event {
case let .standardOutput(bytes), let .standardError(bytes):
print(String(decoding: bytes, as: UTF8.self), terminator: "")
case let .exitStatus(status):
print("exit status:", status)
case let .exitSignal(exitSignal):
print("exit signal:", exitSignal.signal.rawValue)
case .endOfFile:
print("remote EOF")
}
}
}
}The Default PTY
If you do not pass a custom request, Traversio uses SSHPseudoTerminalRequest.default:
| Field | Default value |
|---|---|
terminalType | xterm-256color |
characterWidth | 80 |
characterHeight | 24 |
pixelWidth | 0 |
pixelHeight | 0 |
encodedTerminalModes | [0] |
You can override that when you know the target environment needs different values:
let request = SSHPseudoTerminalRequest(
terminalType: "vt100",
characterWidth: 120,
characterHeight: 40,
pixelWidth: 0,
pixelHeight: 0,
encodedTerminalModes: [0]
)
let session = try await connection.openShell(pseudoTerminalRequest: request)Sending Shell Environment Variables
Use the optional environment: parameter when the remote shell should receive one or more SSH env requests before startup:
let session = try await connection.openShell(
environment: [
SSHSessionEnvironmentVariable(name: "LANG", value: "en_US.UTF-8"),
SSHSessionEnvironmentVariable(name: "TERM", value: "xterm-256color"),
]
)Traversio waits for a reply to each environment request before it sends the PTY and shell requests. A rejected environment request fails shell startup and keeps the startup boundary explicit.
Writing Input
SSHSession supports both raw-byte and UTF-8 string input:
try await session.write(Array("echo $SHELL\n".utf8))
try await session.write("exit\n")Use sendEOF() when you want to close the sending side explicitly:
try await session.sendEOF()Use close() when you need to close the shell channel itself without closing the whole SSH connection:
try await session.close()Resizing The PTY
SSHSession.resizePseudoTerminal(...) sends an SSH window-change request on the existing session channel:
try await session.resizePseudoTerminal(
characterWidth: 132,
characterHeight: 43,
pixelWidth: 1440,
pixelHeight: 900
)This is mainly useful for PTY-backed shells opened through openShell(...).
openExec(...) reuses the same SSHSession wrapper, and exec sessions keep their default non-PTY behavior unless you build a separate PTY flow.
Sending Signals
SSHSession.sendSignal(_:) sends an SSH signal request on the current session channel:
try await session.sendSignal(.interrupt)
try await session.sendSignal(.terminate)Use standard signal names through SSHSessionSignal, or build a custom value with SSHSessionSignal(rawValue: ...) when the peer expects an implementation-specific name.
This request does not have a reply message, and RFC 4254 allows servers to ignore it.
Reading Output
The shell wrapper gives you three read styles:
nextEvent()for one event at a timeeventsforAsyncSequence-style consumptionreadStandardOutputChunk()orcollectOutputUntilClose()for the older chunked/transcript convenience APIs
Event-stream example:
for try await event in session.events {
switch event {
case let .standardOutput(bytes):
print("stdout:", String(decoding: bytes, as: UTF8.self))
case let .standardError(bytes):
print("stderr:", String(decoding: bytes, as: UTF8.self))
case let .exitStatus(status):
print("exit status:", status)
case let .exitSignal(exitSignal):
print("exit signal:", exitSignal.signal.rawValue)
case .endOfFile:
print("EOF")
}
}Single-event example:
while let event = try await session.nextEvent() {
print(event)
}Chunked stdout example:
while let chunk = try await session.readStandardOutputChunk() {
print(String(decoding: chunk, as: UTF8.self), terminator: "")
}Full transcript example:
let transcript = try await session.collectOutputUntilClose()
print(String(decoding: transcript.standardOutput, as: UTF8.self))
print(String(decoding: transcript.standardError, as: UTF8.self))
print(transcript.exitStatus as Any)
print(transcript.exitSignal as Any)SSHSessionEvent has these cases:
.standardOutput([UInt8]).standardError([UInt8]).exitStatus(UInt32).exitSignal(SSHSessionExitSignal).endOfFile
Limits
The shell API already covers the main interactive workflow and still has a few defined limits.
Known gaps:
- the shell event stream is the current incremental API, but the older chunked reader only covers stdout
- shell, streamed exec, and raw forwarding channels share the same event-stream direction, and wrapper specialization remains an active design question
eventsornextEvent()provide the cleanest incremental view on a given session- session lifetime follows the owning
SSHConnection - shell operations observe task cancellation, and
collectOutputUntilClose()plussession.eventsiteration attempt a best-effortchannel-close