Files
Termind/Frameworks/TermindRemoteInfrastructure/Agent/AgentCommandOutputStream.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

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) }
}
}