Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift
T
Kaede Akino ebeb0027d4 feat(ssh)!: redesign command execution tools
Split command execution into run, output, stdin, cancel and list tools for the assistant and MCP. Preserve bounded output tails and use conversation-scoped handles and secret storage with AgentKit 1.20.0.

Add the built-in ssh-exec skill with timestamped log redirection and text/secret stdin workflows. Expand long tool output without adjacent copy controls, update localized presentation, and refresh regression coverage and the website tool name.

BREAKING CHANGE: Replace ssh_exec and ssh_wait with ssh_exec_run and ssh_exec_output. Move stdin to ssh_exec_stdin and remove execution timeout and cwd arguments. Use run_in_background to control foreground execution.
2026-10-17 12:26:48 +08:00

91 lines
3.2 KiB
Swift

import Foundation
import TermindApplication
/// Controls one non-PTY exec channel, independently of the pooled connection.
nonisolated public final class SSHExecSession: @unchecked Sendable {
public typealias Writer = @Sendable (SSHCommandInput?) async throws -> Int
public let keepsStdinOpen: Bool
public let cancellation = TransferCancellationToken()
private let lock = NSLock()
private var startup: Result<Void, any Error>?
private var waiter: CheckedContinuation<Void, any Error>?
private var writer: Writer?
private var closed: Bool
private var didStart = false
public init(keepsStdinOpen: Bool) {
self.keepsStdinOpen = keepsStdinOpen
closed = !keepsStdinOpen
}
public var started: Bool { lock.withLock { didStart } }
public var stdinClosed: Bool { lock.withLock { closed } }
public func waitUntilStarted() async throws {
try await withCheckedThrowingContinuation { continuation in
let result: Result<Void, any Error>? = lock.withLock {
if let startup { return startup }
waiter = continuation
return nil
}
if let result { continuation.resume(with: result) }
}
}
public func start(writer: @escaping Writer) {
resolveStartup(.success(()), writer: writer)
}
public func finish(error: (any Error)? = nil) {
resolveStartup(.failure(error ?? CancellationError()), writer: nil)
lock.withLock {
writer = nil
closed = true
}
}
private func resolveStartup(_ result: Result<Void, any Error>, writer: Writer?) {
let continuation = lock.withLock {
guard startup == nil else { return Optional<CheckedContinuation<Void, any Error>>.none }
startup = result
self.writer = writer
if case .success = result { didStart = true }
defer { waiter = nil }
return waiter
}
continuation?.resume(with: result)
}
public func write(_ input: SSHCommandInput?) async throws -> Int {
let writer = try lock.withLock {
if closed {
if input == nil { return Optional<Writer>.none }
throw TermindSSHError.library("Command input is closed.")
}
guard let writer else { throw TermindSSHError.library("Command input is unavailable.") }
return writer
}
guard let writer else { return 0 }
let count = try await withTaskCancellationHandler {
try Task.checkCancellation()
return try await writer(input)
} onCancel: {
self.cancellation.cancel()
}
if input == nil { lock.withLock { closed = true } }
return count
}
}
/// Byte-bounded UTF-8 tail. Drop an incomplete leading scalar, never split a valid one.
nonisolated enum SSHOutputTail {
@discardableResult
static func append(_ chunk: Data, to data: inout Data, limit: Int) -> Bool {
data.append(chunk)
guard data.count > max(0, limit) else { return false }
data = Data(data.suffix(max(0, limit)))
while let first = data.first, first & 0xC0 == 0x80 { data.removeFirst() }
return true
}
}