// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AppKit import Combine import Foundation import GhosttyTerminal import GhosttyTheme import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import struct TermindDomain.Host /// Workspace-owned routing for terminal input and shell completion. /// Class-bound so a sshSession can keep the relationship weak while panes move /// between Workspaces. protocol SSHSessionInputRouting: AnyObject { func routeInteractiveInput(_ data: Data, from origin: SSHSessionModel) func sshSessionDidInvalidateCommandGeneration(_ sshSession: SSHSessionModel) func sshSession( _ sshSession: SSHSessionModel, didChangeWorkingDirectory workingDirectory: String? ) func sshSessionDidChangeRemoteSystemInfo(_ sshSession: SSHSessionModel) func closeSSHSessionPane(afterExit sshSession: SSHSessionModel) } /// One terminal sshSession inside a pane. /// /// The screen buffer lives inside libghostty. This model bridges its in-memory /// terminal backend to one SSH session and exposes connection state to SwiftUI. @Observable final class SSHSessionModel: RemotePathDisplaying, Identifiable { let commandHistory = CommandHistoryModel() var id: UUID { commandHistory.sessionID } /// Never optional. Termind ships no local terminal — the App Sandbox has no /// route to a login shell worth having, and shipping a crippled one would be /// worse than shipping none — so a sshSession is always a sshSession *on* a host. var hostID: Host.ID var title: String var transport: HostTerminalProtocol = .ssh private(set) var transportState: ResumableTerminalState? var status: ConnectionStatus = .disconnected { didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) synchronizeResizeThrottle() } } var workingDirectory: String? { didSet { guard workingDirectory != oldValue else { return } invalidateCommandGeneration() inputRouter?.sshSession( self, didChangeWorkingDirectory: workingDirectory ) } } private(set) var homeResolution: RemoteHomeResolutionState = .pending var displayedWorkingDirectory: String? { guard let workingDirectory, !homeResolution.isPending else { return nil } return displayPath(workingDirectory) } let commandMonitor = TerminalCommandMonitor() private var commandObservation: TerminalCommandObservation? var hasUnreadOutput: Bool = false /// Find stays with this terminal's scrollback rather than with the transient /// SwiftUI pane mounting it, so moving a terminal between panes carries its /// open search and retained query along with the buffer they search. Closing /// the bar ends Ghostty's highlights but deliberately keeps the query, /// matching VS Code when ⌘F is used again in the same terminal. /// /// Two properties rather than one struct, because Observation invalidates /// per stored property. A single `find` value meant every letter typed into /// the titlebar Find field invalidated everything that only wanted to know /// whether the search was *open* — which is `WorkspaceContentView`, and /// which rebuilt every pane in the Workspace and remounted every Ghostty /// surface, once per keystroke. @ObservationIgnored lazy var find = TerminalFindController( perform: { [weak self] in self?.performFindBinding($0) }, focus: { [weak self] in self?.requestTerminalFocus() } ) /// Resolved once at the beginning of each connection attempt. private(set) var isShellIntegrationEnabled = true /// Whether the remote shell accepted Termind's authenticated OSC integration. var shellIntegrationAvailable: Bool = false private(set) var integratedShell: String? private(set) var remoteSystemInfo: RemoteSystemInfo? { didSet { guard remoteSystemInfo != oldValue else { return } inputRouter?.sshSessionDidChangeRemoteSystemInfo(self) } } private(set) var isShellPromptReady = false private struct ActiveShellCommand { var historyID: CommandHistoryEntry.ID? } private var activeShellCommand: ActiveShellCommand? let terminalContext = TerminalContextCoordinator() private var hasRuntimeContext = false private var historyCharset: RemoteCharset = .utf8 private var contextHistory: [TerminalContextIdentity: UUID] = [:] private(set) var commandGeneratorRevision: UInt64 = 0 private let commandDraft = TerminalCommandDraftController() /// Non-nil while a connection is waiting for the user to approve a first or /// additional server fingerprint. No credential is sent before this clears. var pendingHostKey: SSHHostKeyPrompt? { connection.pendingHostKey } /// Set when this connection needs a login or credentials. The terminal view /// presents the editor; the sshSession remains disconnected until it receives /// either Continue or Continue and Save. var pendingAuthentication: SSHAuthenticationPrompt? { get { connection.pendingAuthentication } set { connection.pendingAuthentication = newValue } } let connection = RemoteSessionConnectionController() var connectionJourney: ConnectionJourney { connection.journey } private let terminalSurface: PersistentTerminalViewOwner var terminalState: TerminalViewState { terminalSurface.state } /// Owns the native terminal across SplitNode reparenting. Keeping this /// beside the sshSession makes the surface lifetime match the SSH sshSession, /// rather than the transient SwiftUI branch that currently displays it. var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface } private weak var statusStore: (any RemoteSessionHostStore)? private var ssh: SSHPTYChannel? var connectionCoordinator: RemoteConnectionCoordinator? private var connectionTask: Task? private var startsOnMount = true private(set) var connectionAttemptID: UUID? { get { connection.connectionAttemptID } set { let oldValue = connection.connectionAttemptID connection.connectionAttemptID = newValue if newValue != oldValue { terminalUpload.cancelPendingUpload() commandHistory.cancelLoading() } } } @ObservationIgnored lazy var terminalUpload = TerminalUploadModel(session: self) private var reconnectController: SessionReconnectController { connection.reconnect } private var workingDirectoryObservation: AnyCancellable? private var terminalActivityObservation: AnyCancellable? private(set) var isTerminalActive = false /// The live terminal's frozen route and credential IDs. Password, Key, and /// passphrase material are resolved only for each attempted transfer /// connection and never retained here. Cleared with the SSH connection. private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot? private var persistentSessionSnapshot: SSHAdditionalConnectionSnapshot? var isRemoteSessionClosing = false var restorableRemoteSession: RemoteSessionReference? { isRemoteSessionClosing ? nil : sessionPersistence.attachment } private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24) var connectionRoute: SSHConnectionRoute? { get { connection.connectionRoute } set { connection.connectionRoute = newValue } } var connectionOrigin: RemoteConnectionOrigin = .library var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence } private var transientHost: Host? { get { connection.transientHost } set { connection.transientHost = newValue } } private var usernameHint: String? private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] { get { connection.suppliedAuthentications } set { connection.suppliedAuthentications = newValue } } private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] { get { connection.suppliedProxyAuthentications } set { connection.suppliedProxyAuthentications = newValue } } private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] { get { connection.temporaryHostKeys } set { connection.temporaryHostKeys = newValue } } /// Installed by the Workspace currently holding this sshSession. Weak because /// the Workspace owns the pane, which owns this model. weak var inputRouter: (any SSHSessionInputRouting)? /// Injection point for routing tests. Production sessions leave this nil /// and write to their SSH actor as before. var inputSink: ((Data) -> Void)? /// A caller-supplied command submitted once after the first successful /// connection. Authentication and host-key prompts keep it alive; failure, /// cancellation, or an explicit disconnect consumes it without running. /// Commands generated solely to synchronize an SFTP path opt out of History. private struct PendingInitialCommand { var command: String var recordsInHistory: Bool } let sessionPersistence = RemoteSessionCoordinator() private var pendingInitialCommand: PendingInitialCommand? /// Re-resolved from the Host and Snippet library for each physical terminal shell. private var pendingConnectionStartupCommand: PendingInitialCommand? private var commandExcludedFromHistory: String? private var isInitialCommandSubmissionReady = false private var initialCommandStartupTicket: SSHOrdinaryStartupTicket? var reconnectState: ReconnectState? { connection.reconnectState } var acceptsTerminalInput: Bool { !transport.isResumable || (!connectionJourney.isPresented && transportState != .recovering && transportState != .suspended && transportState != .failed && transportState != .ended && transportState != .closed) } func openPTYSFTPChannel(for attemptID: UUID?) async throws -> any SFTPChannelProtocol { guard status == .connected, connectionAttemptID == attemptID, let ssh else { throw SFTPError.notConnected } let client = try await ssh.openSFTPChannel() guard status == .connected, connectionAttemptID == attemptID, self.ssh === ssh else { await client.disconnect() throw CancellationError() } return client } convenience init(hostID: Host.ID, title: String, initialCommand: String? = nil) { self.init( hostID: hostID, title: title, initialCommand: initialCommand, recordsInitialCommandInHistory: true ) } convenience init( hostID: Host.ID, title: String, initialCommand: String?, recordsInitialCommandInHistory: Bool ) { self.init( hostID: hostID, title: title, transientHost: nil, usernameHint: nil, initialCommand: initialCommand, recordsInitialCommandInHistory: recordsInitialCommandInHistory ) } convenience init(host: Host, usernameHint: String? = nil, initialCommand: String? = nil) { self.init( host: host, usernameHint: usernameHint, initialCommand: initialCommand, recordsInitialCommandInHistory: true ) } convenience init( host: Host, usernameHint: String? = nil, initialCommand: String?, recordsInitialCommandInHistory: Bool ) { self.init( hostID: host.id, title: host.name, transientHost: host, usernameHint: usernameHint, initialCommand: initialCommand, recordsInitialCommandInHistory: recordsInitialCommandInHistory ) } private init( hostID: Host.ID, title: String, transientHost: Host?, usernameHint: String?, initialCommand: String?, recordsInitialCommandInHistory: Bool ) { self.hostID = hostID self.title = title self.usernameHint = usernameHint self.pendingInitialCommand = initialCommand.map { PendingInitialCommand( command: $0, recordsInHistory: recordsInitialCommandInHistory ) } let relay = SSHSessionRelay() let surface = PersistentTerminalViewOwner( write: { data, isInteractiveInput in Task { @MainActor in relay.owner?.handleTerminalWrite( data, isInteractiveInput: isInteractiveInput ) } }, resize: { viewport in Task { @MainActor in relay.owner?.resizeSSH(viewport) } } ) terminalSurface = surface self.transientHost = transientHost relay.owner = self commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor) observeWorkingDirectory(from: surface.state, through: relay) terminalActivityObservation = surface.state.$isFocused .combineLatest(surface.state.$isSurfaceVisible) .map { $0 && $1 } .removeDuplicates() .map { _ in () } .merge( with: NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification).map { _ in () }, NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification).map { _ in () } ) .sink { [weak relay] _ in Task { @MainActor in relay?.owner?.synchronizeTerminalActivity() } } sessionPersistence.didChangePrompt = { [weak self] pending in if pending { self?.connectionJourney.waitForSessionSelection() } else { self?.connectionJourney.resume() } } } var agentTemporaryTarget: AgentHostTarget? { guard statusStore?.host(hostID) == nil, let host = transientHost else { return nil } let state: String switch status { case .disconnected: state = "disconnected" case .connecting: state = "connecting" case .connected: state = "connected" case .reconnecting: state = "reconnecting" case .failed: state = "failed" } return AgentHostTarget( id: host.id, displayName: host.name, address: host.displayAddress, tags: host.tags, connectionState: state, isTemporary: true, groupID: nil, groupPath: nil ) } var agentConnectionConfiguration: SSHConnectionConfiguration? { guard let transferSnapshot, let statusStore else { return nil } return try? statusStore.additionalConnectionRequest(from: transferSnapshot).configuration } var transferHostStore: (any RemoteSessionHostStore)? { statusStore } func additionalConnectionRequest( from snapshot: SSHAdditionalConnectionSnapshot ) throws -> SSHConnectionRequest { guard let statusStore else { throw TermindSSHError.missingCredential } return try statusStore.additionalConnectionRequest(from: snapshot) } func requestTerminalFocus() { guard !connectionJourney.isPresented, sessionPersistence.prompt == nil else { return } terminalSurface.requestFocus() } private func performFindBinding(_ action: String) { terminalSurface.performBindingAction(action) } /// Text arriving at this sshSession's prompt — the one way anything reaches a /// sshSession. /// /// Every programmatic surface that types into a shell comes through here: a /// snippet or an AI command card. One choke point /// makes broadcast possible — see `Workspace.deliver`; `submit` appends the /// newline that the user's own Return key would otherwise supply. func insert(_ text: String, submit: Bool = false) { if let statusStore, let host = statusStore.host(hostID), (try? statusStore.requireHostAccess(host)) == nil { return } invalidateCommandGeneration() writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8)) } /// Runtime prompt snapshots can outlive foreground ownership or tmux copy/prefix mode. /// A read-draft ticket confirms that the prompt is still interactive. private func synchronizeResizeThrottle() { let milliseconds: Double if !status.isLive || !isShellIntegrationEnabled || !shellIntegrationAvailable { milliseconds = 35 } else if isShellPromptReady, !hasRuntimeContext || terminalContext.ticket(requiring: .readDraft) != nil { milliseconds = 0 } else { milliseconds = 128 } terminalSurface.setResizeThrottle(milliseconds: milliseconds) } var canGenerateFromCurrentDraft: Bool { status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady && TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "") && (!hasRuntimeContext || terminalContext.ticket(requiring: [.readDraft, .replaceDraft]) != nil) } func requestCurrentDraft() async throws -> String { if hasRuntimeContext { guard let ticket = terminalContext.ticket(requiring: .readDraft), let context = terminalContext.active, let ssh else { throw TerminalDraftError.unavailable } let value = try await ssh.contextDraft(context) guard terminalContext.isCurrent(ticket, requiring: .readDraft) else { throw TerminalDraftError.unavailable } return value } return try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in self?.writeInputDirectly(data) } } /// Clears the shell's edit buffer through the installed line-editor widget, /// then inserts one literal command as a bracketed paste. There is /// intentionally no carriage return in this payload. func insertUploadedPaths(_ paths: [String]) { let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " " if hasRuntimeContext { replaceCurrentDraft(with: text) return } // Write only to this session: broadcast input must not copy local upload paths. terminalViewOwner.pasteUploadedText(text) } var directoryContextTicket: TerminalContextCoordinator.Ticket? { terminalContext.ticket(requiring: .directory) } func confirmDisplayedDirectory(_ path: String) async -> Bool { guard status.isLive, workingDirectory == path else { return false } guard hasRuntimeContext else { return true } guard let context = terminalContext.active, let ssh else { return false } let generation = terminalContext.generation do { try await ssh.confirmContext(context) return terminalContext.generation == generation && terminalContext.active == context && workingDirectory == path } catch { return false } } func isDirectoryContextCurrent(_ ticket: TerminalContextCoordinator.Ticket?) -> Bool { guard hasRuntimeContext else { return ticket == nil } guard let ticket else { return false } return terminalContext.isCurrent(ticket, requiring: .directory) } func confirmDirectoryContext(_ ticket: TerminalContextCoordinator.Ticket?) async throws { guard hasRuntimeContext else { return } guard isDirectoryContextCurrent(ticket), let context = terminalContext.active, let ssh else { throw TerminalDraftError.unavailable } do { try await ssh.confirmContext(context) guard isDirectoryContextCurrent(ticket) else { throw TerminalDraftError.unavailable } } catch { if isDirectoryContextCurrent(ticket) { receiveRuntimeContext(.init(status: .unknown)) } throw error } } func replaceCurrentDraft(with command: String) { if hasRuntimeContext { guard let ticket = terminalContext.ticket(requiring: .replaceDraft), let context = terminalContext.active, let ssh else { return } Task { [weak self] in guard let self, terminalContext.isCurrent(ticket, requiring: .replaceDraft) else { return } do { _ = try await ssh.contextDraft(context, replacing: command) } catch { if terminalContext.isCurrent(ticket, requiring: .replaceDraft) { receiveRuntimeContext(.init(status: .unknown)) } } } return } writeInputDirectly(commandDraft.replacementPayload(for: command)) } private func invalidateCommandGeneration() { commandGeneratorRevision &+= 1 inputRouter?.sshSessionDidInvalidateCommandGeneration(self) } private func resetCommandGeneratorShellState() { terminalContext.reset() if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) } hasRuntimeContext = false contextHistory.removeAll() integratedShell = nil remoteSystemInfo = nil isShellPromptReady = false shellIntegrationAvailable = false isInitialCommandSubmissionReady = false initialCommandStartupTicket = nil pendingConnectionStartupCommand = nil activeShellCommand = nil commandDraft.cancel() invalidateCommandGeneration() synchronizeResizeThrottle() } func connectIfNeeded(using store: any RemoteSessionHostStore) { guard startsOnMount, status == .connecting else { return } connect(using: store) } func connect(using store: any RemoteSessionHostStore) { if transport.isResumable, let ssh { Task { try? await ssh.retryTransport() } return } startsOnMount = false startConnection(using: store) } private func startConnection(using store: any RemoteSessionHostStore) { commandMonitor.endConnection() resetCommandGeneratorShellState() homeResolution = .unavailable let reconnecting = connection.hasConnectedOnce && connection.isRecovering workingDirectory = nil transferSnapshot = nil commandExcludedFromHistory = nil statusStore = store let attemptID = UUID() connectionAttemptID = attemptID transportState = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil guard let host = connectionHost(hostID, using: store) else { connection.stopRecovery() connectionAttemptID = nil status = .failed(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)) connectionJourney.fail(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)) return } guard host.isEnabled(transport) else { startsOnMount = false status = .failed(TerminalTransportError.unsupportedRoute.localizedDescription) connectionJourney.fail(TerminalTransportError.unsupportedRoute.localizedDescription) return } isShellIntegrationEnabled = transport != .mosh && host.shellIntegrationMode.isEnabled( globalSetting: DesktopSettingsKey.isShellIntegrationEnabled) if pendingInitialCommand == nil, let snippetID = host.startupSnippetID, let body = store.snippet(snippetID)?.body, !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { pendingConnectionStartupCommand = PendingInitialCommand( command: body, recordsInHistory: true ) } homeResolution = isShellIntegrationEnabled ? .pending : .unavailable let route = connectionRoute?.hosts ?? store.route(for: host) let identity = store.identity(for: host) let stagedAuthentication = suppliedAuthentications[host.id] let username = stagedAuthentication?.username ?? (host.isTailscaleManaged ? store.savedTailscaleUsername(for: host) : identity?.username) ?? usernameHint connectionJourney.begin( host: host, route: route, purpose: .terminal(transport), username: username, mode: reconnecting ? .reconnection : .connection ) if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) { requestProxyAuthentication(for: proxyHost, using: store) return } if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route), let submission = suppliedAuthentications[passwordHost.id] { requestAuthentication( for: passwordHost, username: submission.username, selectedKeyID: submission.keyID, requiresPassphrase: false ) return } if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase( in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry ), let submission = suppliedAuthentications[passphraseHost.id] { requestAuthentication( for: passphraseHost, username: submission.username, selectedKeyID: submission.keyID, requiresPassphrase: true, preserveSubmission: true ) return } guard let username, !username.trimmingCharacters(in: .whitespaces).isEmpty else { requestAuthentication( for: host, username: "", selectedKeyID: identity?.keyID, requiresPassphrase: false ) return } let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets() let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets() let suppliedAuthentication = attemptAuthentications[host.id] var credentials: [SSHCredentials] = [] var credentialFailures: [String] = [] var hasConfiguredCredential = false let selectedKeyID: SSHKeyRef.ID? if let suppliedAuthentication { selectedKeyID = suppliedAuthentication.keyID } else { selectedKeyID = identity?.keyID } if let suppliedAuthentication { hasConfiguredCredential = suppliedAuthentication.keyID != nil || suppliedAuthentication.hasPassword || suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true if let keyID = suppliedAuthentication.keyID { if let key = store.key(keyID) { do { if let credential = try store.connectionKeySecretCredential(for: key) { credentials.append( credential.sshCredential( for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection() ) ) } else { credentialFailures.append( String(localized: AppStrings.Terminal.theSelectedKeyHasNoSavedPrivateKey) ) } } catch { credentialFailures.append(error.localizedDescription) } } else { credentialFailures.append( String(localized: AppStrings.Terminal.theSelectedKeyIsNoLongerAvailable) ) } } if let password = suppliedAuthentication.passwordForConnection() { credentials.append(.password(password)) } else if let identityID = suppliedAuthentication.identityID, let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword { do { if let password = try store.connectionPasswordSecret(for: selectedIdentity) { credentials.append(.password(password)) } else { credentialFailures.append( String(localized: AppStrings.Terminal.theSavedPasswordIsNoLongerAvailable) ) } } catch { credentialFailures.append(error.localizedDescription) } } } else if let identity { let resolution = store.sshCredentials(for: identity) if let keyID = resolution.requiredPassphraseKeyID { requestAuthentication( for: host, username: username, selectedKeyID: keyID, requiresPassphrase: true ) return } credentials = resolution.credentials credentialFailures = resolution.failures hasConfiguredCredential = resolution.hasConfiguredCredential } if credentials.isEmpty, suppliedAuthentication == nil, hasConfiguredCredential { requestAuthentication( for: host, username: username, selectedKeyID: selectedKeyID, failureMessage: hasConfiguredCredential ? credentialFailures.joined(separator: "\n") : nil ) return } let proxy: SSHTransportProxy? let jumps: [SSHRouteHopConfiguration] let expectedHostKeys: Set do { proxy = try store.transportProxy( for: host, submission: attemptProxyAuthentications[host.id]) if let connectionRoute { jumps = try store.sshJumpConfigurations( for: connectionRoute, submissions: attemptAuthentications, proxySubmissions: attemptProxyAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) } else { jumps = try store.sshJumpConfigurations( for: host, submissions: attemptAuthentications, proxySubmissions: attemptProxyAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) } expectedHostKeys = try store.expectedHostKeys( for: host, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) } catch { if let sshError = error as? TermindSSHError, sshError.shouldRequestAuthentication, let id = sshError.routeHostID, let jump = connectionHost(id, using: store) { if sshError.isProxyAuthentication { connectionAttemptID = nil requestProxyAuthentication( for: jump, using: store, failureMessage: sshError.localizedDescription, mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity) return } let identity = store.identity(for: jump) let submitted = suppliedAuthentications[jump.id] let retryNeedsPassphrase = submitted?.hasPassphrase != true && (submitted?.keyID ?? identity?.keyID) .flatMap { store.key($0) }?.requiresPassphraseEntry == true connectionAttemptID = nil requestAuthentication( for: jump, username: submitted?.username ?? identity?.username ?? connectionRoute?.usernames[jump.id] ?? "", selectedKeyID: submitted?.keyID ?? identity?.keyID, failureMessage: sshError.localizedDescription, requiresPassphrase: retryNeedsPassphrase, preserveSubmission: retryNeedsPassphrase && submitted != nil ) return } connectionAttemptID = nil handleConnectionAttemptFailure( error, store: store, reconnecting: reconnecting ) return } connection.clearHostKeyPrompt() connectionTask?.cancel() status = reconnecting ? .reconnecting : .connecting let authenticationReference: SSHAuthenticationReference if let identityID = suppliedAuthentication?.identityID ?? (suppliedAuthentication == nil ? identity?.id : nil) { authenticationReference = SSHAuthenticationReference( identityID: identityID, username: username, permitsNone: identity?.hasSecret == false, hasEphemeralSecret: suppliedAuthentication?.hasPassword == true || suppliedAuthentication?.hasPassphrase == true ) } else { authenticationReference = SSHAuthenticationReference( fallbackKeyID: selectedKeyID, username: username, permitsNone: selectedKeyID == nil && suppliedAuthentication?.hasPassword != true, hasEphemeralSecret: suppliedAuthentication?.hasPassword == true || suppliedAuthentication?.hasPassphrase == true ) } connectionTask = Task { [weak self, weak store] in guard let self, let store else { return } do { try await store.resolveHostAccess(host, route: connectionRoute) try await openSSH( host: host, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, authenticationReference: authenticationReference, attemptID: attemptID, authorizeHostKey: { [weak self] challenge in guard let self else { throw CancellationError() } return await self.connection.authorizeHostKey(challenge) } ) } catch is CancellationError { guard connectionAttemptID == attemptID else { return } connectionAttemptID = nil status = .disconnected connection.stopRecovery() connectionJourney.fail(String(localized: AppStrings.Connections.connectionCancelled)) } catch { guard connectionAttemptID == attemptID else { return } connectionAttemptID = nil if let sshError = error as? TermindSSHError, sshError.shouldRequestAuthentication { let authenticationHost: Host if let id = sshError.routeHostID, let jump = connectionHost(id, using: store) { authenticationHost = jump } else { authenticationHost = host } let authenticationIdentity = store.identity(for: authenticationHost) if sshError.isProxyAuthentication { requestProxyAuthentication( for: authenticationHost, using: store, failureMessage: sshError.localizedDescription, mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity) return } let submitted = suppliedAuthentications[authenticationHost.id] let authenticationKeyID = submitted?.keyID ?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil) let retryNeedsPassphrase = submitted?.requiresPasswordReentry != true && submitted?.hasPassphrase != true && authenticationKeyID.flatMap { store.key($0) }?.hasPassphrase == true let returnsToIdentity = submitted?.requiresPassphraseReentry == true requestAuthentication( for: authenticationHost, username: submitted?.username ?? authenticationIdentity?.username ?? connectionRoute?.usernames[authenticationHost.id] ?? (authenticationHost.id == host.id ? username : ""), selectedKeyID: authenticationKeyID, failureMessage: (credentialFailures + [sshError.localizedDescription]).joined(separator: "\n"), requiresPassphrase: retryNeedsPassphrase, mode: !returnsToIdentity && sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity, preserveSubmission: retryNeedsPassphrase && submitted != nil ) } else { handleConnectionAttemptFailure( error, store: store, reconnecting: reconnecting ) } } } } func continueAuthentication( identityID: Identity.ID?, username: String, password: String?, passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool, using store: any RemoteSessionHostStore ) { connection.continueAuthentication( identityID: identityID, username: username, password: password, passphrase: passphrase, keyID: keyID, save: save, destinationID: hostID, using: store ) { host, submission in if host.id == hostID { usernameHint = submission.username } connectionRoute?.usernames[host.id] = submission.username } saveIdentity: { host, submission, selectedIdentity in if let route = connectionRoute { let saved = try store.saveConnectionRoute( route, authentication: submission, authenticationHostID: host.id, suppliedPassword: password) adoptSavedRoute(saved, replacing: route, using: store) } else { let saved = try store.saveAuthentication( submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password) if saved.id == hostID { transientHost = saved } } } onFailure: { failure in switch failure { case .proxy(let host, let error): requestProxyAuthentication(for: host, using: store, failureMessage: error.localizedDescription) case .identity(let host, let username, let keyID, _, let error): requestAuthentication( for: host, username: username, selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: keyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true) } } connect: { connect(using: store) } } func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) { connection.trustHostKey(observation, using: store) } func continueWithTemporaryHostKey(_ observation: SSHHostKeyObservation) { _ = connection.useTemporaryHostKey(observation) } func disconnect() { sessionPersistence.cancel() commandHistory.cancelLoading() terminalUpload.cancelPendingUpload() commandMonitor.endConnection() startsOnMount = false resetCommandGeneratorShellState() workingDirectory = nil homeResolution = .unavailable transferSnapshot = nil pendingInitialCommand = nil pendingConnectionStartupCommand = nil commandExcludedFromHistory = nil connectionAttemptID = nil connection.disconnect() connectionTask?.cancel() connectionTask = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil if transport.isResumable { transportState = .closed } status = .disconnected } func retryReconnectNow() { if transport.isResumable { guard transportState == .recovering, let ssh else { return } let attemptID = connectionAttemptID Task { [weak self] in do { try await ssh.retryTransport() } catch { guard let self, self.connectionAttemptID == attemptID else { return } self.receiveTransportState(.failed, failure: error.localizedDescription) } } return } reconnectController.retryNow() } func stopReconnecting() { guard let reconnectState else { return } connection.stopRecovery() connectionAttemptID = nil connectionTask?.cancel() connectionTask = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil status = .failed(reconnectState.lastError) connectionJourney.fail(reconnectState.lastError) } private func openSSH( host: Host, username: String, credentials: [SSHCredentials], expectedHostKeys: Set, proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration], authenticationReference: SSHAuthenticationReference, attemptID: UUID, authorizeHostKey: @escaping SSHHostKeyAuthorizer ) async throws { let wasReconnecting = connection.isRecovering let presentsConnectionJourney = connectionJourney.isPresented var configuration = SSHConnectionConfiguration( hostID: host.id, host: host.host, port: host.port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, charset: host.charset, authenticationReference: authenticationReference ) guard let store = statusStore else { throw TailscaleConfigurationError.unavailable } configuration.accessHostIDs = jumps.map(\.id) + [host.id] + (connectionRoute.map { Array($0.sourceHostIDs.values) } ?? []) configuration.accessCheck = store.connectionAccessCheck(for: configuration.accessHostIDs) configuration.beforeConnect = store.connectionWakeAction(for: configuration.accessHostIDs) configuration.network = try store.tailscaleNetwork( for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host) configuration.tailscaleNodeID = host.tailscale?.stableNodeID let terminalSurface = terminalSurface commandMonitor.beginConnection() commandMonitor.requireScopedEvents() let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel( output: { data in terminalSurface.receive(data) }, exit: { [weak self] result in Task { @MainActor in guard let self, self.connectionAttemptID == attemptID else { return } self.handleRemoteExit(result) } }, shellIntegration: { [weak self] event in Task { @MainActor in guard let self, self.connectionAttemptID == attemptID else { return } self.receiveShellIntegrationEvent(event) } } ) ssh = actor historyCharset = host.charset do { if transport.isResumable { let snapshot = store.additionalConnectionSnapshot(from: configuration) let requestFactory: @Sendable (Bool) async throws -> SSHConnectionRequest = { [weak self, weak store] authenticatingTarget in guard let self, let store, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await store.resumableRouteRequest( from: snapshot, authorizeHostKey: authorizeHostKey, keyboardInteractive: { [weak self] challenge in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.answerKeyboardInteractive(challenge) }, securityKeySigning: { [weak self] request in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.signWithSecurityKey(request) }, hostKeyConstraints: self.connectionOrigin.hostKeyConstraints, authenticatingTarget: authenticatingTarget) } await actor.configureTransport( transport, options: host.terminalTransports, requestFactory: requestFactory, stateChanged: { [weak self] state in Task { @MainActor in guard let self, self.connectionAttemptID == attemptID else { return } self.receiveTransportState(state) } }, routeChanged: { [weak self] route in Task { @MainActor in guard let self, self.connectionAttemptID == attemptID else { return } self.receiveTransportRoute(route) } }) } await actor.configureEnvironment(host.environmentVariables) { variables in let list = variables.joined(separator: ", ") let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list)) terminalSurface.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8)) } let persistence = sessionPersistence let explicit = pendingInitialCommand?.command let startup = explicit ?? pendingConnectionStartupCommand?.command let integration = isShellIntegrationEnabled if host.sessionPersistence.enabled || persistence.attachment != nil || persistence.requiresSelection { persistence.didChangeAttachment = { [weak self] reference in guard let self, self.connectionAttemptID == attemptID, reference != nil else { return } self.pendingInitialCommand = nil self.pendingConnectionStartupCommand = nil } await actor.configureStartup { execute in try await persistence.prepare( preferences: host.sessionPersistence, registry: .live, execute: execute, hostID: host.id, endpoint: "\(host.host):\(host.sshPort)", username: username, startupCommand: startup, requiresNewSession: explicit != nil, shellIntegration: integration, environment: host.environmentVariables) } } await actor.setTerminalActive(isTerminalActive) guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() } if transport == .ssh { // A retained surface may not emit another resize for this new channel. // Prime all dimensions before connect consumes the worker's initial viewport. let viewport = latestViewport await actor.resize( columns: viewport.columns, rows: viewport.rows, widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels) } guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() } try await actor.connect( using: SSHConnectionRequest( configuration: configuration, authorizeHostKey: authorizeHostKey, keyboardInteractive: { [weak self] challenge in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.answerKeyboardInteractive(challenge) }, securityKeySigning: { [weak self] request in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.signWithSecurityKey(request) }, hostKeyConstraints: connectionOrigin.hostKeyConstraints ), shellIntegrationEnabled: isShellIntegrationEnabled, columns: latestViewport.columns, rows: latestViewport.rows, progress: connection.progressHandler(attemptID: attemptID) ) try Task.checkCancellation() guard connectionAttemptID == attemptID else { throw CancellationError() } guard transportState != .ended else { return } if transportState == .recovering || transportState == .suspended || transportState == .failed { return } transferSnapshot = statusStore?.additionalConnectionSnapshot(from: configuration) ?? SSHAdditionalConnectionSnapshot(configuration: configuration) persistentSessionSnapshot = statusStore?.additionalConnectionSnapshot(from: configuration) suppliedAuthentications.removeAll() suppliedProxyAuthentications.removeAll() temporaryHostKeys.removeAll() _ = connection.markConnected() status = .connected if transport == .ssh { if hasRuntimeContext, terminalContext.active?.historySource != nil { loadContextHistory() } else { commandHistory.load( using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil) } } if sessionPersistence.attachment != nil { pendingInitialCommand = nil pendingConnectionStartupCommand = nil homeResolution = .unavailable } if presentsConnectionJourney, connectionJourney.phase != .succeeded { connectionJourney.succeed() } else if !presentsConnectionJourney { connectionJourney.hide() } if wasReconnecting, transport == .ssh, sessionPersistence.attachment == nil { appendTerminalNotice( String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted) ) } if !isShellIntegrationEnabled { isInitialCommandSubmissionReady = true } submitPendingInitialCommandIfReady() } catch { await actor.disconnect() if ssh === actor { ssh = nil } throw error } } func receiveTransportRoute(_ route: TerminalRouteSnapshot) { guard transportState != .ended, transportState != .closed else { return } prepareTransportRecoveryJourney() connectionJourney.receiveTransportRoute(route) } func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) { guard transportState != .ended, transportState != .closed else { return } guard state != transportState || failure != nil else { return } transportState = state let context = connection.transportJourneyContext() switch state { case .connected: status = .connected if connectionJourney.transportRecovery != nil { _ = connection.markConnected() } case .recovering, .suspended: status = .reconnecting case .ended: // These protocols report session end, not the remote process's numeric exit status. handleRemoteExit(.success(0)) case .failed: connection.resetRecovery() let message = failure ?? TerminalTransportError.sessionFailed.localizedDescription status = .failed(message) // Transport cleanup can follow a failure; the pane owns dismissing its Journey. case .connecting, .closed: break } connectionJourney.receiveTransportState( state, context: context, failure: failure, recoveryMessage: { $0.statusMessage }, prepareRecovery: prepareTransportRecoveryJourney) } private func prepareTransportRecoveryJourney() { if connectionJourney.endpoints.isEmpty { let host = connectionRoute?.host(hostID) ?? statusStore?.host(hostID) ?? transientHost ?? Host(id: hostID, name: title, host: title) connectionJourney.begin( host: host, route: connectionRoute?.hosts ?? [host], purpose: .terminal(transport), username: connectionRoute?.usernames[hostID] ?? usernameHint, mode: .reconnection) } } func takePersistentSessionCloseRequest(hosts: any RemoteSessionHostStore) -> RemoteSessionCloseRequest? { guard let reference = sessionPersistence.attachment, reference.isManaged else { return nil } let snapshot: SSHAdditionalConnectionSnapshot if let persistentSessionSnapshot { snapshot = persistentSessionSnapshot } else { guard let host = connectionRoute?.host(reference.hostID) ?? hosts.host(reference.hostID) ?? transientHost, let restored = try? hosts.resumableControlSnapshot(for: host, username: reference.username) else { return nil } snapshot = restored } guard snapshot.hostID == reference.hostID, "\(snapshot.target.host):\(snapshot.target.port)" == reference.endpoint else { return nil } let transferred = transport == .ssh ? ssh : nil if transferred != nil { ssh = nil } return RemoteSessionCloseRequest( reference: reference, snapshot: snapshot, connection: transferred.map { RemoteSessionCloseConnection(channel: $0) }) } /// Normal shell exit closes the pane, not the reusable Ghostty surface. /// Transport failures remain visible for reconnection. func handleRemoteExit(_ result: Result) { resetCommandGeneratorShellState() workingDirectory = nil terminalUpload.cancelPendingUpload() if case .success(let exitCode) = result, let reference = sessionPersistence.attachment, let channel = ssh { let attemptID = connectionAttemptID Task { [weak self] in guard let self else { return } var sessionExists: Bool? do { let execute = try await channel.sessionCommandExecutor() sessionExists = try await RemoteSessionBackendRegistry.live.sessionExists( reference, execute: execute) } catch { // An unverified exit must not erase identity or start a replacement session. } guard self.connectionAttemptID == attemptID, self.sessionPersistence.attachment == reference else { return } let shouldClose = self.sessionPersistence.finishExit(exitCode: exitCode, sessionExists: sessionExists) await channel.disconnect() guard self.connectionAttemptID == attemptID else { return } self.ssh = nil self.connection.resetRecovery() self.status = .disconnected if shouldClose { self.inputRouter?.closeSSHSessionPane(afterExit: self) return } self.appendTerminalNotice( String( localized: exitCode != 0 ? AppStrings.SessionPersistence.failed : self.sessionPersistence.detached ? AppStrings.SessionPersistence.detached : AppStrings.SessionPersistence.ended)) } return } resetCommandGeneratorShellState() workingDirectory = nil homeResolution = .unavailable transferSnapshot = nil connectionAttemptID = nil ssh = nil switch result { case .success: connection.resetRecovery() status = .disconnected inputRouter?.closeSSHSessionPane(afterExit: self) case .failure(let error): if transport.isResumable { receiveTransportState(.failed, failure: error.localizedDescription) } else { beginAutomaticReconnect(after: error.localizedDescription) } } } private func handleConnectionAttemptFailure( _ error: any Error, store: any RemoteSessionHostStore, reconnecting: Bool ) { connectionAttemptID = nil if transport.isResumable { connection.resetRecovery() status = .failed(error.localizedDescription) connectionJourney.fail(error.localizedDescription) return } if connection.hasConnectedOnce || reconnecting { scheduleReconnect(after: error.localizedDescription, using: store) } else { status = .failed(error.localizedDescription) connectionJourney.fail( error.localizedDescription, at: (error as? TermindSSHError)?.routeHostID ) } } private func beginAutomaticReconnect(after message: String) { guard connection.hasConnectedOnce, let store = statusStore else { status = .failed(message) return } appendTerminalNotice(String(localized: AppStrings.Connections.connectionLostReconnectingProgress)) scheduleReconnect(after: message, using: store) } private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) { connection.scheduleReconnect( after: message, hostID: hostID, using: store, purpose: .terminal(transport), route: connectionRoute, usernameHint: usernameHint ) { resetCommandGeneratorShellState() connectionAttemptID = nil connectionTask?.cancel() connectionTask = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil transferSnapshot = nil status = .reconnecting } retry: { [weak self] store in self?.startConnection(using: store) } } private func appendTerminalNotice(_ message: String) { terminalSurface.appendNotice(message) } private func requestAuthentication( for host: Host, username: String, selectedKeyID: SSHKeyRef.ID?, failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: SSHAuthenticationPrompt.Mode = .localIdentity, preserveSubmission: Bool = false ) { guard let store = statusStore else { return } connection.pauseForUserInteraction() if !preserveSubmission { suppliedAuthentications[host.id] = nil } pendingAuthentication = SSHAuthenticationPromptResolver.host( host, using: store, submission: preserveSubmission ? suppliedAuthentications[host.id] : nil, username: username, selectedKeyID: selectedKeyID, failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil, mode: mode, requiresPassphrase: requiresPassphrase, allowsSaving: connectionOrigin.allowsPersistence ) status = .disconnected if host.isTailscaleManaged { connectionJourney.waitForTailscaleUsername(at: host.id, failure: failureMessage) return } switch mode { case .localIdentity: if requiresPassphrase { connectionJourney.waitForLocalPassphrase( at: host.id, failure: failureMessage ) } else { connectionJourney.waitForLocalIdentity( at: host.id, failure: failureMessage ) } case .serverChallenge: connectionJourney.waitForAuthentication(at: host.id, failure: failureMessage) } } private func requestProxyAuthentication( for host: Host, using store: any RemoteSessionHostStore, failureMessage: String? = nil, mode: SSHAuthenticationPrompt.Mode = .localIdentity ) { guard host.proxy != nil else { return } connection.pauseForUserInteraction() let staged = suppliedProxyAuthentications[host.id] suppliedProxyAuthentications[host.id] = nil pendingAuthentication = SSHAuthenticationPromptResolver.proxy( for: host, using: store, submission: staged, failureMessage: failureMessage, mode: mode, allowsSaving: connectionOrigin.allowsPersistence) status = .disconnected connectionJourney.waitForProxyAuthentication(at: host.id, failure: failureMessage) } func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? { connection.connectionHost(id, destinationID: hostID, using: store) } private func adoptSavedRoute( _ saved: SSHConnectionRoute, replacing route: SSHConnectionRoute, using store: any RemoteSessionHostStore ) { let mapping = Dictionary(uniqueKeysWithValues: zip(route.hosts, saved.hosts).map { ($0.id, $1.id) }) suppliedAuthentications = Dictionary( uniqueKeysWithValues: suppliedAuthentications.map { (mapping[$0.key] ?? $0.key, $0.value) }) suppliedProxyAuthentications = Dictionary( uniqueKeysWithValues: suppliedProxyAuthentications.map { (mapping[$0.key] ?? $0.key, $0.value) }) temporaryHostKeys = Dictionary( uniqueKeysWithValues: temporaryHostKeys.map { (mapping[$0.key] ?? $0.key, $0.value) }) if let destination = saved.hosts.last { if hostID != destination.id { store.setConnectionStatus(.disconnected, connectionID: id, hostID: hostID) } hostID = destination.id transientHost = destination usernameHint = saved.usernames[destination.id] } connectionRoute = saved } func copyForReconnect() -> SSHSessionModel { let host = transientHost ?? Host(id: hostID, name: title, host: title) let copy = SSHSessionModel(host: host, usernameHint: usernameHint) copy.transport = transport copy.sessionPersistence.attachment = restorableRemoteSession copy.connectionRoute = connectionRoute copy.connectionOrigin = connectionOrigin copy.connectionCoordinator = connectionCoordinator return copy } /// Routes terminal-produced bytes only when they came from an explicitly /// captured user interaction. Mouse/focus reports and protocol replies use /// the direct path even while Broadcast Input is enabled. func handleTerminalWrite(_ data: Data, isInteractiveInput: Bool) { guard !isInteractiveInput || acceptsTerminalInput else { return } if isInteractiveInput { invalidateCommandGeneration() } if isInteractiveInput, let inputRouter { inputRouter.routeInteractiveInput(data, from: self) } else { writeInputDirectly(data, isInteractiveInput: isInteractiveInput) } } /// The terminal side of the routing boundary. Broadcast targets call this /// rather than feeding bytes back through the router, preventing recursion. func writeInputDirectly(_ data: Data, isInteractiveInput: Bool = true) { guard !isInteractiveInput || acceptsTerminalInput else { return } terminalContext.noteInput() if let inputSink { inputSink(data) return } guard let ssh else { return } Task { await ssh.write(data) } } private func resizeSSH(_ viewport: InMemoryTerminalViewport) { guard viewport.columns > 0, viewport.rows > 0 else { return } latestViewport = viewport guard let ssh else { return } let attemptID = connectionAttemptID Task { [weak self] in guard let self, self.ssh === ssh, self.connectionAttemptID == attemptID else { return } let viewport = self.latestViewport await ssh.resize( columns: viewport.columns, rows: viewport.rows, widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels ) } } private func synchronizeTerminalActivity() { // First responder can be retained in a non-key window. Window events must // recheck native focus even when Ghostty's last published value is unchanged. let active = terminalState.isFocused && terminalState.isSurfaceVisible && terminalViewOwner.hasActiveKeyboardFocus guard active != isTerminalActive else { return } isTerminalActive = active guard let ssh else { return } Task { [weak self] in guard let self, self.ssh === ssh, self.isTerminalActive == active else { return } await ssh.setTerminalActive(active) } } /// Ghostty parses OSC 7 and publishes the decoded remote path on its view /// state. Bridge that dependency-owned state into the sshSession so every Termind /// surface reads one observable source of truth. private func observeWorkingDirectory( from state: TerminalViewState, through relay: SSHSessionRelay ) { workingDirectoryObservation = state.$workingDirectory.sink { [weak relay] path in Task { @MainActor in guard let owner = relay?.owner, owner.isShellIntegrationEnabled, !owner.hasRuntimeContext else { return } owner.workingDirectory = path?.isEmpty == false ? path : nil } } } func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) { guard isShellIntegrationEnabled else { return } if case .ordinaryStartupUnavailable(let ticket) = event { guard sessionPersistence.attachment == nil else { return } guard let ticket else { pendingInitialCommand = nil pendingConnectionStartupCommand = nil initialCommandStartupTicket = nil return } initialCommandStartupTicket = ticket isInitialCommandSubmissionReady = true submitPendingInitialCommandIfReady() return } if case .runtime(let update) = event { receiveRuntimeContext(update) return } guard !hasRuntimeContext else { return } defer { synchronizeResizeThrottle() } commandMonitor.receiveShellEvent(event) switch event { case .runtime, .ordinaryStartupUnavailable: break case .available(let shell, let systemInfo, let homeDirectory): if let homeDirectory, homeDirectory.hasPrefix("/") { homeResolution = .resolved(RemotePath.normalized(homeDirectory)) } else { homeResolution = .unavailable } integratedShell = shell remoteSystemInfo = systemInfo statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID) isShellPromptReady = false shellIntegrationAvailable = true isInitialCommandSubmissionReady = true invalidateCommandGeneration() inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory) submitPendingInitialCommandIfReady() case .systemInfo(let systemInfo): remoteSystemInfo = systemInfo statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID) case .homeResolutionUnavailable: homeResolution = .unavailable inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory) isInitialCommandSubmissionReady = true submitPendingInitialCommandIfReady() case .promptReady: isShellPromptReady = true isInitialCommandSubmissionReady = true invalidateCommandGeneration() submitPendingInitialCommandIfReady() case .commandStarted(let command): isShellPromptReady = false invalidateCommandGeneration() activeShellCommand = ActiveShellCommand( historyID: recordExecutedCommand(command) ) case .commandFinished(let status, let duration): recordCommandCompletion(exitStatus: status, reportedDuration: duration) case .draft(let draft): commandDraft.receive(draft) } } private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) { defer { synchronizeResizeThrottle() } hasRuntimeContext = true let previous = terminalContext.revision let events = terminalContext.receive(update, generation: terminalContext.generation) loadContextHistory() commandMonitor.receiveRuntimeEvents(events) if previous != terminalContext.revision { commandDraft.cancel() terminalUpload.cancelPendingUpload() invalidateCommandGeneration() } workingDirectory = terminalContext.directory integratedShell = terminalContext.active?.shell shellIntegrationAvailable = terminalContext.active?.capabilities.contains(.commandLifecycle) == true isShellPromptReady = terminalContext.active?.promptReady == true if let home = terminalContext.active?.home { homeResolution = .resolved(RemotePath.normalized(home)) } else { homeResolution = .unavailable } for event in events { switch event.payload { case .commandStarted(let command): contextHistory[event.context] = recordExecutedCommand(command) case .commandFinished(let status, let duration): if let id = contextHistory.removeValue(forKey: event.context) { commandHistory.recordCompletion(for: id, exitStatus: status, duration: duration) } case .draft(_, let value): if terminalContext.active?.identity == event.context { commandDraft.receive(value) } default: break } } if update.status == .ordinary, isShellPromptReady { isInitialCommandSubmissionReady = true submitPendingInitialCommandIfReady() } } private func loadContextHistory() { let context = terminalContext.active let source = commandHistory.selectContext(context, generation: terminalContext.generation) guard transport == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else { return } commandHistory.load( using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source) } /// Submits an explicit one-shot launch command, or this connection's freshly /// resolved Host startup command. Keeping the History exclusion next to the /// write ensures it is armed only when the command actually runs. func submitPendingInitialCommand() { let initialCommand: PendingInitialCommand if let explicit = pendingInitialCommand { initialCommand = explicit pendingInitialCommand = nil } else if let startup = pendingConnectionStartupCommand { initialCommand = startup pendingConnectionStartupCommand = nil } else { return } if !initialCommand.recordsInHistory { commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations( in: initialCommand.command ) } if let ticket = initialCommandStartupTicket { initialCommandStartupTicket = nil if let statusStore, let host = statusStore.host(hostID), (try? statusStore.requireHostAccess(host)) == nil { return } let data = Data((initialCommand.command + "\r").utf8) if let inputSink { inputSink(data) } else if let ssh { Task { await ssh.submitInitialCommand(data, ticket: ticket) } } } else { insert(initialCommand.command, submit: true) } } private func submitPendingInitialCommandIfReady() { guard status.isLive, isInitialCommandSubmissionReady else { return } submitPendingInitialCommand() } @discardableResult func recordExecutedCommand(_ command: String) -> CommandHistoryEntry.ID? { let command = CommandHistoryEntry.normalizingLineContinuations(in: command) guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } if command == commandExcludedFromHistory { commandExcludedFromHistory = nil return nil } return commandHistory.record(command)?.id } private func recordCommandCompletion( exitStatus: Int32, reportedDuration: TimeInterval? ) { guard let active = activeShellCommand else { return } activeShellCommand = nil guard let historyID = active.historyID else { return } commandHistory.recordCompletion(for: historyID, exitStatus: exitStatus, duration: reportedDuration) } } private final class SSHSessionRelay: @unchecked Sendable { weak var owner: SSHSessionModel? } // MARK: - Demo data extension SSHSessionModel { /// A connected-looking session with a real terminal surface and synthetic, /// non-sensitive output. Screenshot mode uses this instead of opening a /// network connection or reading somebody's actual scrollback. static func demo(host: Host, transcript: String? = nil) -> SSHSessionModel { let sshSession = SSHSessionModel(host: host) sshSession.status = .connected sshSession.workingDirectory = "/var/log/nginx" sshSession.homeResolution = .resolved("/home/demo") sshSession.shellIntegrationAvailable = true sshSession.integratedShell = "zsh" sshSession.isShellPromptReady = true let bytes = Data((transcript ?? demoTranscript(for: host.name)).utf8) // Drive the same delegate callback that OSC 7 Shell Integration emits. // Feed the screen only after the native surface has mounted and sized; // writing into the default 80-column grid first leaves Ghostty's cursor // behind when a narrow demo pane reflows that content. Task { [weak sshSession] in try? await Task.sleep(for: .milliseconds(350)) sshSession?.terminalSurface.receive(Data("\u{001B}[2J\u{001B}[H".utf8)) sshSession?.terminalSurface.receive(bytes) // The demo has no remote PTY to synchronize Ghostty's interactive // cursor with received output. Hide that cursor for static captures // instead of advertising a caret at a position no shell reported. sshSession?.terminalSurface.receive(Data("\u{001B}[?25l".utf8)) sshSession?.terminalState.terminalDidChangeWorkingDirectory("/var/log/nginx") } return sshSession } private static func demoTranscript(for host: String) -> String { let prompt = "\u{001B}[36mdeploy@\(host)\u{001B}[0m " + "\u{001B}[34m/var/log/nginx\u{001B}[0m %" return [ "\u{001B}]7;file://\(host)/var/log/nginx\u{0007}", "\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m", prompt + " systemctl status nginx --no-pager", "● nginx.service - A high performance web server", " Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)", " Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC", prompt + " tail -n 3 access.log", "10.0.4.21 - - [27/Aug/2026:09:41:12] \"GET /health HTTP/1.1\" 200 18", "10.0.4.18 - - [27/Aug/2026:09:41:14] \"GET /api/status HTTP/1.1\" 200 52", prompt + " ", ].joined(separator: "\r\n") } }