Traversio

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:

FieldDefault value
terminalTypexterm-256color
characterWidth80
characterHeight24
pixelWidth0
pixelHeight0
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:

  1. nextEvent() for one event at a time
  2. events for AsyncSequence-style consumption
  3. readStandardOutputChunk() or collectOutputUntilClose() 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
  • events or nextEvent() provide the cleanest incremental view on a given session
  • session lifetime follows the owning SSHConnection
  • shell operations observe task cancellation, and collectOutputUntilClose() plus session.events iteration attempt a best-effort channel-close

On this page