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.
78 lines
3.1 KiB
Swift
78 lines
3.1 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
|
|
/// A bounded UTF-8 tail with an in-band count of discarded Unicode scalars.
|
|
nonisolated struct AgentCommandOutputBuffer {
|
|
private var data = Data()
|
|
private var discardedCharacters = 0
|
|
let limit: Int
|
|
|
|
private var marker: String {
|
|
discardedCharacters == 0 ? "" : "[\(discardedCharacters) chars were truncated]\n"
|
|
}
|
|
|
|
var text: String { marker + String(decoding: data, as: UTF8.self) }
|
|
|
|
mutating func append(_ chunk: Data) {
|
|
data.append(chunk)
|
|
// The marker also counts toward the byte budget. Its digit count can grow
|
|
// after a trim, so tighten again until both the text and marker fit.
|
|
while data.count + marker.utf8.count > limit, !data.isEmpty {
|
|
var count = min(data.count, max(1, data.count + marker.utf8.count - limit))
|
|
while count < data.count, data[data.startIndex + count] & 0xC0 == 0x80 { count += 1 }
|
|
discardedCharacters += String(decoding: data.prefix(count), as: UTF8.self).unicodeScalars.count
|
|
data = Data(data.dropFirst(count))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Locked because the serial SSH pump cannot await an actor. Only progress is
|
|
/// throttled; snapshots always contain the latest bounded tail.
|
|
nonisolated public final class AgentCommandOutputStream: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var standardOut: AgentCommandOutputBuffer
|
|
private var standardError: AgentCommandOutputBuffer
|
|
private var lastReportedAt: ContinuousClock.Instant?
|
|
private let interval: Duration
|
|
private var report: @Sendable (String, String) -> Void
|
|
|
|
public init(
|
|
interval: Duration = .milliseconds(150), limit: Int = 256 * 1_024,
|
|
report: @escaping @Sendable (String, String) -> Void
|
|
) {
|
|
self.interval = interval
|
|
standardOut = AgentCommandOutputBuffer(limit: limit)
|
|
standardError = AgentCommandOutputBuffer(limit: limit)
|
|
self.report = report
|
|
}
|
|
|
|
public func redirect(to report: @escaping @Sendable (String, String) -> Void) {
|
|
let output = lock.withLock {
|
|
self.report = report
|
|
lastReportedAt = nil
|
|
return (standardOut.text, standardError.text)
|
|
}
|
|
report(output.0, output.1)
|
|
}
|
|
|
|
public var handler: SSHExecOutputHandler {
|
|
{ [self] chunk, isStandardError in append(chunk, isStandardError: isStandardError) }
|
|
}
|
|
|
|
public var captured: (standardOut: String, standardError: String) {
|
|
lock.withLock { (standardOut.text, standardError.text) }
|
|
}
|
|
|
|
private func append(_ chunk: Data, isStandardError: Bool) {
|
|
typealias Due = (String, String, @Sendable (String, String) -> Void)
|
|
let due: Due? = lock.withLock {
|
|
if isStandardError { standardError.append(chunk) } else { standardOut.append(chunk) }
|
|
let now = ContinuousClock.now
|
|
if let lastReportedAt, now - lastReportedAt < interval { return nil }
|
|
lastReportedAt = now
|
|
return (standardOut.text, standardError.text, report)
|
|
}
|
|
if let due { due.2(due.0, due.1) }
|
|
}
|
|
}
|