feat(sessions): unify multiplexer backends and preserve cwd

Add Zellij, zmx, and Herdr alongside tmux using shared lifecycle, shell startup, journal, and runtime infrastructure. Keep verified shell directories visible during commands without restoring unsafe operation capabilities.

Cover backend ownership, startup replay, runtime context, and desktop/mobile cwd regressions. Preserve conservative degradation where client focus cannot be verified.
This commit is contained in:
Kaede Akino committed 2026-10-16 18:50:55 +08:00
1 parent 7d77acd3ed
commit 5a74809da8
34 files changed
+2487 -215

No files matched your search

@@ -0,0 +1,63 @@
import Foundation
import TermindApplication
import TermindDomain
nonisolated enum RemoteHerdrCommands {
private struct Listing: Decodable {
let sessions: [Session]
struct Session: Decodable {
let name: String
let running: Bool
let socketPath: String
let sessionDir: String
let connectionError: String?
enum CodingKeys: String, CodingKey {
case name, running
case socketPath = "socket_path"
case sessionDir = "session_dir"
case connectionError = "connection_error"
}
}
}
static var list: String {
RemoteMultiplexerKind.herdr.discovery + "\n\"$exe\" session list --json"
}
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
let value: Listing
do { value = try JSONDecoder().decode(Listing.self, from: Data(text.utf8)) } catch {
throw RemoteSessionFailure.invalidResponse
}
return try value.sessions.compactMap { session in
guard session.connectionError == nil else { throw RemoteSessionFailure.invalidResponse }
guard RemoteMultiplexerCommands.validName(session.name),
RemoteMultiplexerCommands.validPath(session.sessionDir),
session.socketPath == session.sessionDir + "/herdr.sock"
else { throw RemoteSessionFailure.invalidResponse }
guard session.running else { return nil }
return .init(name: session.name, root: session.sessionDir, socket: session.socketPath)
}
}
static func create(name: String) -> String {
"""
root="${XDG_CONFIG_HOME:-$HOME/.config}/herdr/sessions"
mkdir -p "$root" || exit 78
[ ! -L "$root" ] || exit 79
mkdir "$root/\(name)" || exit 79
chmod 700 "$root/\(name)" || exit 78
socket="$root/\(name)/herdr.sock"
# Only the new named server inherits the wrapper; the user's config remains untouched.
native_started=1
SHELL="$helper/shell" HERDR_STARTUP_CWD="$PWD" nohup "$exe" --session '\(name)' server \
</dev/null >"$meta/create-error" 2>&1 &
attempt=0
while [ ! -S "$socket" ]; do
attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 78
sleep 0.1
done
"""
}
}
@@ -0,0 +1,327 @@
import Foundation
import TermindApplication
import TermindDomain
nonisolated struct RemoteMultiplexerDraftTarget {
let root: String
let trigger: String
}
public nonisolated struct RemoteMultiplexerRuntimeAdapter: TerminalRuntimeAdapter {
private let attachment: RemoteSessionReference?
private let execute: RemoteSessionCommandExecutor
private let charset: RemoteCharset
public init(
attachment: RemoteSessionReference? = nil, execute: @escaping RemoteSessionCommandExecutor,
charset: RemoteCharset = .utf8
) {
self.attachment = attachment
self.execute = execute
self.charset = charset
}
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
if let attachment, attachment.backend != .tmux {
return try await RemoteSocketMultiplexerRuntime(
reference: attachment, execute: execute, charset: charset
).inspect(terminal: terminal)
}
return try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).inspect(terminal: terminal)
}
static func helper(for attachment: RemoteSessionReference?) throws -> String? {
guard let attachment, attachment.backend != .tmux else { return nil }
guard let kind = RemoteMultiplexerKind(rawValue: attachment.backend.rawValue) else {
throw TerminalRuntimeError.unsupported
}
return try RemoteMultiplexerIdentity.decode(attachment.session.identity, kind: kind).helper
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
if let attachment, attachment.backend != .tmux {
return try await RemoteSocketMultiplexerRuntime(
reference: attachment, execute: execute, charset: charset
).draftTarget(context: context, terminal: terminal, replacing: replacing)
}
let update = try await inspect(terminal: terminal)
guard update.active == context.identity,
update.contexts.first(where: { $0.identity == context.identity }) == context
else { throw TerminalRuntimeError.staleContext }
return try RemoteTmuxRuntimeDialect.draftTarget(
context: context, terminal: terminal, clientIdentity: update.clientIdentity, replacing: replacing)
}
func refreshSize(of client: RemoteTmuxClientIdentity) async throws {
try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).refreshSize(of: client)
}
}
nonisolated struct RemoteSocketMultiplexerRuntime: TerminalRuntimeAdapter {
let reference: RemoteSessionReference
let execute: RemoteSessionCommandExecutor
var charset: RemoteCharset = .utf8
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
try await snapshot(terminal: terminal).update
}
private struct Snapshot {
let update: TerminalRuntimeUpdate
let native: String
let focus: String
}
private var identity: RemoteMultiplexerIdentity {
get throws {
guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue) else {
throw TerminalRuntimeError.unsupported
}
return try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
}
}
private func nativeCommands() throws -> (String, String) {
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
switch try identity.kind {
case .tmux: throw TerminalRuntimeError.unsupported
case .zellij:
return ("\"$exe\" list-sessions --short --no-formatting", "\"$exe\" --session \(name) action list-clients")
case .zmx: return ("\"$exe\" list", "printf 'shell'")
case .herdr: return ("\"$exe\" session list --json", "\"$exe\" --session \(name) pane list")
}
}
private func clientGuard(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal), let helper = try identity.helper else {
throw TerminalRuntimeError.unsupported
}
let kind = try identity.kind.rawValue
return """
helper=\(try RemoteMultiplexerCommands.quote(helper))
\(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false))
meta="$state/\(reference.session.name)"
private_state || exit 79
[ "$(cat "$meta/helper")" = "$helper" ] || exit 79
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
key=$(printf '%s' \(try RemoteMultiplexerCommands.quote(terminal)) | od -An -v -tx1 | tr -d ' \\n')
[ ! -L "$helper" ] && [ ! -L "$helper/client-$key" ] || exit 79
IFS='|' read -r client started < "$helper/client-$key" || exit 79
case "$client" in ''|*[!0-9]*) exit 79 ;; esac
[ -n "$started" ] && [ "$(process_start "$client")" = "$started" ] || exit 79
client_tty=$(ps -p "$client" -o tty= | tr -d ' ')
[ "/dev/$client_tty" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79
# Without a native TTY-to-client API, any other interactive client makes focus ambiguous.
clients=$(ps -u "$(id -u)" -o pid= -o tty= -o comm= | \
awk '$2 != "?" && $2 != "??" {n=split($3,a,"/"); if(a[n]=="\(kind)") print $1}')
[ "$clients" = "$client" ] || exit 79
"""
}
private var shellCheck: String {
get throws {
"""
shell_check() {
case "$1" in ''|*[!0-9]*) return 1 ;; esac
case "$2" in bash|zsh|fish) ;; *) return 1 ;; esac
[ -d "$3" ] && [ ! -L "$3" ] && [ ! -L "$3/process-start" ] || return 1
started=$(cat "$3/process-start") || return 1
[ -n "$started" ] && [ "$(process_start "$1")" = "$started" ] || return 1
parent=$(ps -p "$1" -o ppid= | tr -d ' ') || return 1
\(try identity.process.map { "[ \"$1\" = '\($0)' ] && [ \"$parent\" = '\(try identity.daemonProcess!)' ] || return 1" } ?? "")
parent_name=$(ps -p "$parent" -o comm=)
[ "${parent_name##*/}" = '\(try identity.kind.rawValue)' ] || return 1
name=$(ps -p "$1" -o comm=)
[ "${name##*/}" = "$2" ] || [ "${name##*/}" = "-$2" ] || return 1
}
foreground_shell() {
shell_check "$1" "$2" "$3" || return 1
groups=$(ps -p "$1" -o pgid= -o tpgid=) || return 1
set -- $groups
[ "$#" = 2 ] && [ "$1" = "$2" ]
}
"""
}
}
private func snapshot(terminal: String) async throws -> Snapshot {
let (nativeCommand, focusCommand) = try nativeCommands()
let command = """
\(try RemoteMultiplexerCommands.guardIdentity(reference))
\(try clientGuard(terminal: terminal))
\(try shellCheck)
native=$(\(nativeCommand)) || exit 79
focus=$(\(focusCommand)) || exit 79
printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")"
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
[ "$tag" = M2 ] && [ "$backend" = '\(try identity.kind.rawValue)' ] || continue
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ "${dir##*/}" = "$instance" ] || continue
shell_check "$pid" "$shell" "$dir" || continue
foreground=0
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
printf 'BEGIN|%s\\n' "$foreground"
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
"""
let result = try await execute(command)
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
return try Self.parseSnapshot(result.stdout, reference: reference, charset: charset)
}
static func parse(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8
) throws -> TerminalRuntimeUpdate {
try parseSnapshot(text, reference: reference, charset: charset).update
}
private static func parseSnapshot(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset
) throws -> Snapshot {
guard text.utf8.count <= 1024 * 1024,
let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue)
else { throw TerminalRuntimeError.invalidResponse }
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
let lines = text.components(separatedBy: "\n")
guard lines.count >= 5, lines[0].hasPrefix("C|"), UInt64(lines[0].dropFirst(2)) != nil,
lines[1].hasPrefix("N|"), lines[2].hasPrefix("A|"), lines.suffix(2) == ["DONE", ""]
else { throw TerminalRuntimeError.invalidResponse }
let native = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 512 * 1024)
let focus = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 512 * 1024)
let activePane = try activePane(kind: kind, native: native, focus: focus, reference: reference)
var contexts: [TerminalShellContext] = []
var events: [TerminalContextEvent] = []
var active: TerminalContextIdentity?
var index = 3
while index < lines.count - 2 {
guard lines[index] == "BEGIN|0" || lines[index] == "BEGIN|1" else {
throw TerminalRuntimeError.invalidResponse
}
let foreground = lines[index] == "BEGIN|1"
index += 1
let start = index
while index < lines.count - 2, lines[index] != "END" { index += 1 }
guard index < lines.count - 2 else { throw TerminalRuntimeError.invalidResponse }
let record = try RemoteShellContextRecord(lines[start..<index].joined(separator: "\n"), charset: charset)
index += 1
guard record.backend == kind.rawValue else { throw TerminalRuntimeError.invalidResponse }
var context = record.context(
server: identity.fingerprint, session: reference.session.id, backend: kind.rawValue)
if !foreground { context.capabilities.remove(.directory) }
if record.pane == activePane {
guard active == nil else { throw TerminalRuntimeError.invalidResponse }
active = context.identity
if foreground, record.ready { context.capabilities.formUnion([.readDraft, .replaceDraft]) }
}
contexts.append(context)
events.append(contentsOf: record.events(for: context))
}
guard contexts.count <= 128 else { throw TerminalRuntimeError.invalidResponse }
return Snapshot(
update: .init(
status: .multiplexer, clientIdentity: String(lines[0].dropFirst(2)),
active: active, contexts: contexts, events: events),
native: native, focus: focus)
}
private static func activePane(
kind: RemoteMultiplexerKind, native: String, focus: String, reference: RemoteSessionReference
) throws -> String? {
switch kind {
case .tmux: throw TerminalRuntimeError.unsupported
case .zellij:
// A switched client cannot be bound using its original argv. Require a unique live session too.
guard native == reference.session.name else { return nil }
let clients = focus.split(separator: "\n")
guard clients.first == "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND", clients.count == 2 else { return nil }
let fields = clients[1].split(whereSeparator: \.isWhitespace)
guard fields.count >= 3, UInt64(fields[0]) != nil, fields[1].hasPrefix("terminal_") else { return nil }
let pane = String(fields[1].dropFirst("terminal_".count))
return RemoteShellContextRecord.validPane(pane, backend: "zellij") ? pane : nil
case .zmx:
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
let root = identity.root.utf8.map { String(format: "%02x", $0) }.joined()
let sessions = try RemoteZmxCommands.parseList("ROOT|\(root)\n" + native + "\nEND\n")
guard sessions.count == 1, let session = sessions.first,
session.name == reference.session.name, session.process == identity.process,
session.created == identity.created, session.clients == 1
else { return nil }
return "shell"
case .herdr:
let sessions = try RemoteHerdrCommands.parseList(native)
guard sessions.count == 1, sessions.first?.name == reference.session.name else { return nil }
struct Response: Decodable {
struct Result: Decodable {
struct Pane: Decodable {
let paneID: String
enum CodingKeys: String, CodingKey { case paneID = "pane_id" }
}
let panes: [Pane]
}
let result: Result
}
let response = try JSONDecoder().decode(Response.self, from: Data(focus.utf8))
// Herdr's pane-current is global, not a per-client focus API.
guard response.result.panes.count == 1, let pane = response.result.panes.first?.paneID,
RemoteShellContextRecord.validPane(pane, backend: "herdr")
else { return nil }
// The supported CLI only exposes global pane state, not the attached client's
// session/focus binding (including endpoint switching). Do not infer it from ps.
// Journals remain useful for lifecycle events; active-context operations stay disabled.
_ = pane
return nil
}
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
let snapshot = try await snapshot(terminal: terminal)
guard snapshot.update.active == context.identity,
snapshot.update.contexts.first(where: { $0.identity == context.identity }) == context,
context.capabilities.contains(replacing ? .replaceDraft : .readDraft),
let helper = try identity.helper
else { throw TerminalRuntimeError.staleContext }
let (nativeCommand, focusCommand) = try nativeCommands()
let guardCommand = """
\(try RemoteMultiplexerCommands.guardIdentity(reference))
\(try clientGuard(terminal: terminal))
[ "$(\(nativeCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.native)) ] || exit 79
[ "$(\(focusCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.focus)) ] || exit 79
"""
let key = try RemoteMultiplexerCommands.quote(replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~")
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
let pane = try RemoteMultiplexerCommands.quote(context.identity.pane)
let write: String
switch try identity.kind {
case .tmux: throw TerminalRuntimeError.unsupported
case .zellij: write = "\"$exe\" --session \(name) action write-chars --pane-id \(pane) \(key)"
case .zmx: write = "\"$exe\" send \(name) \(key)"
case .herdr: write = "\"$exe\" --session \(name) pane send-text \(pane) \(key)"
}
return .init(
root: guardCommand + "\nhelper=\(try RemoteMultiplexerCommands.quote(helper))",
trigger: """
(
\(guardCommand)
\(try shellCheck)
IFS='|' read -r tag token pane instance shell home pid history backend < "$dir/meta" || exit 79
[ "$backend" = '\(context.identity.backend)' ] && [ "$pane" = \(pane) ] \
&& [ "$instance" = '\(context.identity.shellInstance)' ] || exit 79
foreground_shell "$pid" "$shell" "$dir" || exit 79
IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79
[ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79
\(write) >/dev/null || exit 79
) || { rm -f "$dir/request"; exit 79; }
""")
}
}
@@ -0,0 +1,45 @@
import Foundation
import TermindApplication
import TermindDomain
/// A single service entry point; command dialects retain their native identity contracts.
public nonisolated struct RemoteMultiplexerService: RemoteSessionBackend {
public typealias Executor = RemoteSessionCommandExecutor
public let id: SessionPersistenceBackendID
private let execute: Executor
public init(backend: SessionPersistenceBackendID = .tmux, execute: @escaping Executor) {
id = backend
self.execute = execute
}
private var backend: RemoteMultiplexerBackend {
get throws {
guard let kind = RemoteMultiplexerKind(rawValue: id.rawValue) else {
throw RemoteSessionFailure.unsupported
}
return .init(kind: kind, execute: execute)
}
}
public var capabilities: RemoteSessionCapabilities {
(try? backend.capabilities) ?? .init()
}
public func probe() async throws -> RemoteSessionCapabilities { try await backend.probe() }
public func sessions() async throws -> [RemoteSessionDescriptor] { try await backend.sessions() }
public func validate(_ reference: RemoteSessionReference) throws { try backend.validate(reference) }
public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
(try? backend.matches(reference, session: session)) ?? false
}
public func create(
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = []
) async throws -> RemoteSessionDescriptor {
try await backend.create(
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, environment: environment)
}
public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
try backend.launch(attachment, created: created)
}
public func terminate(_ attachment: RemoteSessionReference) async throws { try await backend.terminate(attachment) }
}
@@ -0,0 +1,660 @@
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
nonisolated enum RemoteMultiplexerKind: String, Codable, Sendable {
case tmux, zellij, zmx, herdr
var id: SessionPersistenceBackendID { .init(rawValue: rawValue) }
func validateVersion(_ output: String) throws {
if self == .tmux { return try RemoteTmuxCommands.validateVersion(output) }
let fields = output.split(whereSeparator: \.isWhitespace)
guard fields.count >= 2, fields[0].lowercased() == rawValue else {
throw RemoteSessionFailure.invalidResponse
}
let components = fields[1].split(separator: ".")
guard components.count == 3, let major = Int(components[0]), let minor = Int(components[1]),
let patch = Int(components[2]), major >= 0, minor >= 0, patch >= 0
else { throw RemoteSessionFailure.unsupported }
let supported: Bool
// Keep vvterm's supported baselines, not guessed theoretical minimums.
// zmx 0.7 adds ownership labels; Zellij 0.44.3 and Herdr 0.8.2 include lifecycle fixes.
switch self {
case .tmux: supported = false
case .zellij: supported = major > 0 || minor > 44 || (minor == 44 && patch >= 3)
case .zmx: supported = major > 0 || minor >= 7
case .herdr:
supported = major > 0 || minor > 8 || (minor == 8 && patch >= 2) || (minor == 7 && patch >= 5)
}
guard supported else { throw RemoteSessionFailure.unsupported }
}
var discovery: String {
"""
export LC_ALL=C
exe=$(command -v \(rawValue) 2>/dev/null)
case "$exe" in /*) ;; *) exe= ;; esac
if [ -z "$exe" ]; then
for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin "$HOME/.local/bin" "$HOME/.cargo/bin"; do
if [ -f "$dir/\(rawValue)" ] && [ -x "$dir/\(rawValue)" ]; then exe="$dir/\(rawValue)"; break; fi
done
fi
[ -n "$exe" ] && [ -x "$exe" ] || exit 77
unset ZELLIJ ZELLIJ_PANE_ID ZELLIJ_SESSION_NAME ZMX_SESSION ZMX_SESSION_PREFIX
unset HERDR_SESSION HERDR_SOCKET_PATH HERDR_CLIENT_SOCKET_PATH HERDR_PANE_ID HERDR_PANE_RUNTIME_ID
unset HERDR_ACTIVE_PANE_ID HERDR_ACTIVE_TAB_ID HERDR_ACTIVE_WORKSPACE_ID HERDR_ACTIVE_PANE_CWD
unset HERDR_WORKSPACE_ID HERDR_TAB_ID HERDR_STARTUP_CWD HERDR_BIN_PATH
"""
}
}
nonisolated struct RemoteMultiplexerIdentity: Codable, Equatable, Sendable {
var version = 1
let kind: RemoteMultiplexerKind
let root: String
let socket: String
let fingerprint: String
let process: String?
let created: String?
let owner: UUID?
let helper: String?
let isolated: Bool
var directoryFingerprint: String? = nil
var daemonProcess: String? = nil
var daemonStarted: String? = nil
static func decode(_ data: Data, kind: RemoteMultiplexerKind) throws -> Self {
let value: Self
do { value = try JSONDecoder().decode(Self.self, from: data) } catch {
throw RemoteSessionFailure.invalidResponse
}
guard value.version == 1, value.kind == kind, kind != .tmux else {
throw RemoteSessionFailure.unsupported
}
guard RemoteMultiplexerCommands.validPath(value.root), RemoteMultiplexerCommands.validPath(value.socket),
value.socket.hasPrefix(value.root + "/"),
value.fingerprint.split(separator: ":").count == 3,
value.fingerprint.utf8.allSatisfy({ (48...57).contains($0) || $0 == 58 }),
value.helper.map(RemoteMultiplexerCommands.validHelper) ?? true,
value.process.map({ UInt64($0).map { $0 > 1 } ?? false }) ?? true,
value.created.map({ UInt64($0) != nil }) ?? true,
kind != .zmx || (value.process != nil && value.created != nil),
kind != .zmx
|| (value.daemonProcess.flatMap(UInt64.init).map { $0 > 1 } == true
&& value.daemonStarted?.isEmpty == false
&& value.daemonStarted!.utf8.allSatisfy { (32...126).contains($0) }),
value.directoryFingerprint.map({
$0.split(separator: ":").count == 2
&& $0.utf8.allSatisfy { (48...57).contains($0) || $0 == 58 }
}) ?? true,
kind == .zellij || !value.isolated
else { throw RemoteSessionFailure.invalidResponse }
return value
}
}
nonisolated struct RemoteMultiplexerCandidate {
let name: String
let root: String
let socket: String?
var process: String? = nil
var created: String? = nil
var clients: Int? = nil
var isolated = false
}
nonisolated enum RemoteMultiplexerCommands {
static func quote(_ value: String) throws -> String {
guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse }
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
static func validPath(_ value: String) -> Bool {
value.hasPrefix("/") && value.utf8.count <= 4096
&& !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
&& !value.split(separator: "/").contains("..")
}
static func validHelper(_ value: String) -> Bool {
value.hasPrefix("/tmp/termind-shell.") && value.count > "/tmp/termind-shell.".count
&& value.dropFirst("/tmp/termind-shell.".count).utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
}
}
static func validName(_ value: String) -> Bool {
!value.isEmpty && value.utf8.count <= 128 && value.first != "-"
&& value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|| [45, 46, 95].contains($0)
} && value != "." && value != ".."
}
static func managedName(_ token: UUID) -> String {
"termind-"
+ SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8).map {
String(format: "%02x", $0)
}.joined()
}
static func unhex(_ value: String, limit: Int = 8192) throws -> String {
guard value.count.isMultiple(of: 2), value.count <= limit else {
throw RemoteSessionFailure.invalidResponse
}
var bytes: [UInt8] = []
var cursor = value.startIndex
while cursor != value.endIndex {
let end = value.index(cursor, offsetBy: 2)
guard let byte = UInt8(value[cursor..<end], radix: 16) else {
throw RemoteSessionFailure.invalidResponse
}
bytes.append(byte)
cursor = end
}
guard let result = String(bytes: bytes, encoding: .utf8) else {
throw RemoteSessionFailure.invalidResponse
}
return result
}
static let utilities = #"""
hex() { printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n'; }
process_start() { LC_ALL=C TZ=UTC ps -p "$1" -o lstart= | sed 's/^ *//;s/ *$//'; }
fingerprint() {
[ -S "$1" ] && [ ! -L "$1" ] || return 1
stat -c '%d:%i:%Z' "$1" 2>/dev/null || stat -f '%d:%i:%c' "$1"
}
private_dir() {
[ -d "$1" ] || (umask 077; mkdir "$1") || return 1
[ -d "$1" ] && [ ! -L "$1" ] || return 1
owner=$(stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1") || return 1
[ "$owner" = "$(id -u)" ] && chmod 700 "$1"
}
directory_fingerprint() {
[ -d "$1" ] && [ ! -L "$1" ] || return 1
stat -c '%d:%i' "$1" 2>/dev/null || stat -f '%d:%i' "$1"
}
private_state() {
for path in "/tmp/termind-sessions-$(id -u)" "$state" "$meta"; do
[ -d "$path" ] && [ ! -L "$path" ] || return 1
[ "$(stat -c '%u' "$path" 2>/dev/null || stat -f '%u' "$path")" = "$(id -u)" ] || return 1
done
for field in owner helper identity; do
[ -f "$meta/$field" ] && [ ! -L "$meta/$field" ] || return 1
done
}
"""#
static func stateRoot(_ kind: RemoteMultiplexerKind, create: Bool) -> String {
"""
state="/tmp/termind-sessions-$(id -u)"
\(create ? "private_dir \"$state\" || exit 78" : "")
state="$state/\(kind.rawValue)"
\(create ? "private_dir \"$state\" || exit 78" : "")
"""
}
static func environment(_ values: [HostEnvironmentVariable]) throws -> String {
try values.map { value in
guard value.isValid, !value.variable.hasPrefix("TERMIND_"),
!["TMUX", "ZELLIJ", "ZMX", "HERDR"].contains(where: {
value.variable == $0 || value.variable.hasPrefix($0 + "_")
})
else { throw RemoteSessionFailure.invalidResponse }
return "export \(value.variable)=\(try quote(value.value))"
}.joined(separator: "\n")
}
static func routing(_ identity: RemoteMultiplexerIdentity) throws -> String {
switch identity.kind {
case .tmux: throw RemoteSessionFailure.unsupported
case .zellij:
return """
export ZELLIJ_SOCKET_DIR=\(try quote(identity.root))
\(identity.isolated ? "export XDG_CACHE_HOME=\(try quote(identity.root + "/cache"))" : "")
"""
case .zmx: return "export ZMX_DIR=\(try quote(identity.root))"
case .herdr:
let root = identity.root
let config: String
if root.hasSuffix("/herdr") {
config = String(root.dropLast("/herdr".count))
} else if let range = root.range(of: "/herdr/sessions/", options: .backwards),
validName(String(root[range.upperBound...]))
{
config = String(root[..<range.lowerBound])
} else {
throw RemoteSessionFailure.invalidResponse
}
guard validPath(config) else { throw RemoteSessionFailure.invalidResponse }
return "export XDG_CONFIG_HOME=\(try quote(config))"
}
}
static func guardIdentity(_ reference: RemoteSessionReference) throws -> String {
guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue),
reference.version == 1, validName(reference.session.name)
else { throw RemoteSessionFailure.unsupported }
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
try validateRoute(identity, name: reference.session.name)
if let owner = reference.ownershipToken, owner != identity.owner {
throw RemoteSessionFailure.ownershipMismatch
}
let owned =
try reference.ownershipToken.map { token in
let labelGuard =
kind == .zmx
? """
[ "$("$exe" get \(try quote(reference.session.name)) termind_owner)" = '\(token.uuidString)' ] || exit 79
[ "$(cat "$meta/native" 2>/dev/null)" = '\(identity.process!):\(identity.created!)' ] || exit 79
""" : ""
return """
\(stateRoot(kind, create: false))
meta="$state/\(reference.session.name)"
private_state || exit 79
[ "$(cat "$meta/owner" 2>/dev/null)" = '\(token.uuidString)' ] || exit 79
[ "$(cat "$meta/identity" 2>/dev/null)" = \(try quote(identity.fingerprint)) ] || exit 79
\(labelGuard)
"""
} ?? ""
return """
\(utilities)
\(kind.discovery)
\(try routing(identity))
[ "$(fingerprint \(try quote(identity.socket)))" = \(try quote(identity.fingerprint)) ] || exit 79
\(kind == .zmx ? """
[ "$(\(RemoteZmxCommands.nativeIdentity(name: reference.session.name)))" = '\(identity.process!):\(identity.created!)' ] || exit 79
[ "$(ps -p '\(identity.process!)' -o ppid= | tr -d ' ')" = '\(identity.daemonProcess!)' ] || exit 79
[ "$(process_start '\(identity.daemonProcess!)')" = \(try quote(identity.daemonStarted!)) ] || exit 79
""" : "")
\(owned)
"""
}
static func validateRoute(_ identity: RemoteMultiplexerIdentity, name: String) throws {
let valid: Bool
switch identity.kind {
case .tmux: valid = false
case .zmx: valid = identity.socket == identity.root + "/" + name
case .zellij: valid = identity.socket.hasSuffix("/" + name)
case .herdr:
valid =
identity.socket == identity.root + "/herdr.sock"
&& (name == "default"
? identity.root.hasSuffix("/herdr") : identity.root.hasSuffix("/herdr/sessions/" + name))
}
guard valid else { throw RemoteSessionFailure.invalidResponse }
_ = try routing(identity)
}
}
/// Shared ownership and framing policy; native command dialects remain backend-owned.
nonisolated struct RemoteMultiplexerBackend: Sendable {
let kind: RemoteMultiplexerKind
let execute: RemoteSessionCommandExecutor
var capabilities: RemoteSessionCapabilities {
.init(windowCount: kind == .tmux, clientCount: kind == .tmux || kind == .zmx, shellIntegration: true)
}
func probe() async throws -> RemoteSessionCapabilities {
let versionFlag = kind == .tmux ? "-V" : kind == .zmx ? "version" : "--version"
let result = try await execute(kind.discovery + "\n\"$exe\" \(versionFlag)")
try Self.check(result)
try kind.validateVersion(result.stdout)
return capabilities
}
static func check(_ result: RemoteSessionCommandResult) throws {
guard !result.truncated, result.stdout.utf8.count <= 1024 * 1024 else {
throw RemoteSessionFailure.invalidResponse
}
switch result.exitCode {
case 0: return
case 77: throw RemoteSessionFailure.missing
case 79: throw RemoteSessionFailure.ownershipMismatch
case 80: throw RemoteSessionFailure.unsupported
case 81: throw RemoteSessionFailure.startupUnavailable
default: throw RemoteSessionFailure.commandFailed(result.stderr)
}
}
func validate(_ reference: RemoteSessionReference) throws {
guard reference.version == 1, reference.backend == kind.id else { throw RemoteSessionFailure.unsupported }
if kind == .tmux {
_ = try RemoteTmuxCommands.attach(reference)
return
}
guard reference.version == 1, reference.backend == kind.id,
RemoteMultiplexerCommands.validName(reference.session.name)
else { throw RemoteSessionFailure.unsupported }
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
try RemoteMultiplexerCommands.validateRoute(identity, name: reference.session.name)
guard reference.session.id == (identity.isolated ? "managed:" : "user:") + reference.session.name,
reference.ownershipToken == nil || reference.ownershipToken == identity.owner,
reference.ownershipToken.map({ RemoteMultiplexerCommands.managedName($0) == reference.session.name })
?? true
else { throw RemoteSessionFailure.ownershipMismatch }
}
func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
if kind == .tmux {
guard (try? validate(reference)) != nil, reference.session.id == session.id,
let saved = try? TmuxSessionIdentity.decode(reference.session.identity),
let current = try? TmuxSessionIdentity.decode(session.identity)
else { return false }
return saved.created == current.created && saved.serverPID == current.serverPID
&& (reference.ownershipToken.map { current.owner == $0.uuidString } ?? true)
}
guard (try? validate(reference)) != nil, reference.session.id == session.id,
let saved = try? RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind),
let current = try? RemoteMultiplexerIdentity.decode(session.identity, kind: kind)
else { return false }
return saved.root == current.root && saved.socket == current.socket
&& saved.fingerprint == current.fingerprint && saved.process == current.process
&& saved.created == current.created
&& saved.directoryFingerprint == current.directoryFingerprint
&& saved.daemonProcess == current.daemonProcess && saved.daemonStarted == current.daemonStarted
&& (reference.ownershipToken == nil || reference.ownershipToken == current.owner)
}
func sessions() async throws -> [RemoteSessionDescriptor] {
let command: String
switch kind {
case .tmux: command = RemoteTmuxCommands.list
case .zellij: command = RemoteZellijCommands.list
case .zmx: command = RemoteZmxCommands.list
case .herdr: command = RemoteHerdrCommands.list
}
let response = try await execute(command)
try Self.check(response)
let candidates: [RemoteMultiplexerCandidate]
switch kind {
case .tmux: return try RemoteTmuxCommands.parseSessions(response.stdout)
case .zellij: candidates = try RemoteZellijCommands.parseList(response.stdout)
case .zmx: candidates = try RemoteZmxCommands.parseList(response.stdout)
case .herdr: candidates = try RemoteHerdrCommands.parseList(response.stdout)
}
guard candidates.count <= 256, Set(candidates.map { "\($0.isolated):\($0.name)" }).count == candidates.count
else { throw RemoteSessionFailure.invalidResponse }
guard !candidates.isEmpty else { return [] }
let commands = try candidates.enumerated().map { index, candidate -> String in
guard RemoteMultiplexerCommands.validName(candidate.name),
RemoteMultiplexerCommands.validPath(candidate.root)
else { throw RemoteSessionFailure.invalidResponse }
let socket: String
if let path = candidate.socket {
guard RemoteMultiplexerCommands.validPath(path), path.hasPrefix(candidate.root + "/") else {
throw RemoteSessionFailure.invalidResponse
}
socket = "socket=\(try RemoteMultiplexerCommands.quote(path))"
} else {
socket = """
socket=$(find \(try RemoteMultiplexerCommands.quote(candidate.root)) -type s -name \
\(try RemoteMultiplexerCommands.quote(candidate.name)) -print 2>/dev/null) || exit 78
"""
}
return """
\(socket)
fp=$(fingerprint "$socket") || exit 79
owner=-; helper=-; directory=-; daemon=-; born=-
meta="$state/\(candidate.name)"
if private_state && [ "$(cat "$meta/identity" 2>/dev/null)" = "$fp" ]; then
owner=$(cat "$meta/owner") || exit 78
helper=$(cat "$meta/helper") || exit 78
\(kind == .zmx ? """
export ZMX_DIR=\(try RemoteMultiplexerCommands.quote(candidate.root))
if [ "$("$exe" get '\(candidate.name)' termind_owner)" != "$owner" ] \
|| [ "$(cat "$meta/native" 2>/dev/null)" != '\(candidate.process!):\(candidate.created!)' ]; then
owner=-; helper=-
fi
""" : "")
fi
\(kind == .herdr ? "directory=$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(candidate.root))) || exit 79" : "")
\(kind == .zmx ? """
# zmx's list PID is the PTY child, not the daemon. Bind its parent separately.
daemon=$(ps -p '\(candidate.process!)' -o ppid= | tr -d ' ') || exit 79
comm=$(ps -p "$daemon" -o comm=)
[ "${comm##*/}" = zmx ] || exit 79
born=$(process_start "$daemon")
[ -n "$born" ] || exit 79
born=$(hex "$born")
""" : "")
printf 'F|\(index)|%s|%s|%s|%s|%s|%s|%s\\n' "$(hex "$socket")" "$fp" "$owner" "$helper" "$directory" "$daemon" "$born"
"""
}.joined(separator: "\n")
let metadata = try await execute(
kind.discovery + "\n" + RemoteMultiplexerCommands.utilities + "\n"
+ RemoteMultiplexerCommands.stateRoot(kind, create: false)
+ "\n" + commands)
try Self.check(metadata)
let lines = metadata.stdout.split(separator: "\n")
guard metadata.stdout.hasSuffix("\n"), lines.count == candidates.count else {
throw RemoteSessionFailure.invalidResponse
}
return try lines.enumerated().map { index, line in
let fields = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard fields.count == 9, fields[0] == "F", fields[1] == String(index),
fields[4] == "-" || UUID(uuidString: fields[4]) != nil,
fields[5] == "-" || RemoteMultiplexerCommands.validHelper(fields[5])
else { throw RemoteSessionFailure.invalidResponse }
let candidate = candidates[index]
let identity = RemoteMultiplexerIdentity(
kind: kind, root: candidate.root, socket: try RemoteMultiplexerCommands.unhex(fields[2]),
fingerprint: fields[3], process: candidate.process, created: candidate.created,
owner: UUID(uuidString: fields[4]), helper: fields[5] == "-" ? nil : fields[5],
isolated: candidate.isolated, directoryFingerprint: fields[6] == "-" ? nil : fields[6],
daemonProcess: fields[7] == "-" ? nil : fields[7],
daemonStarted: fields[8] == "-" ? nil : try RemoteMultiplexerCommands.unhex(fields[8]))
let data = try JSONEncoder().encode(identity)
_ = try RemoteMultiplexerIdentity.decode(data, kind: kind)
try RemoteMultiplexerCommands.validateRoute(identity, name: candidate.name)
return RemoteSessionDescriptor(
id: (candidate.isolated ? "managed:" : "user:") + candidate.name,
name: candidate.name, identity: data, clients: candidate.clients)
}
}
func create(
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable]
) async throws -> RemoteSessionDescriptor {
if kind == .tmux {
let result = try await execute(
RemoteTmuxCommands.create(
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration,
environment: environment))
try Self.check(result)
let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout)
guard confirmed.count == 1, let session = confirmed.first,
try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString
else { throw RemoteSessionFailure.ownershipMismatch }
return session
}
let name = RemoteMultiplexerCommands.managedName(token)
if let existing = try await sessions().first(where: { $0.name == name }) {
let identity = try RemoteMultiplexerIdentity.decode(existing.identity, kind: kind)
guard identity.owner == token else { throw RemoteSessionFailure.ownershipMismatch }
// An interrupted response may leave a confirmed incarnation without its ready marker.
// Only finish that same incarnation; never replay a startup command or adopt by name.
let reference = RemoteSessionReference(
backend: kind.id, session: existing, hostID: UUID(), endpoint: "", username: "", ownershipToken: token)
let completion = try await execute(
"""
\(try RemoteMultiplexerCommands.guardIdentity(reference))
helper=$(cat "$meta/helper") || exit 79
\(startupCommand?.isEmpty == false ? """
[ -f "$helper/started" ] && [ ! -L "$helper/started" ] \
&& [ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81
""" : "")
: > "$meta/ready"
printf 'TERMIND_CREATED\\n'
""")
try Self.check(completion)
guard completion.stdout == "TERMIND_CREATED\n" else { throw RemoteSessionFailure.invalidResponse }
return existing
}
let native: String
switch kind {
case .tmux: throw RemoteSessionFailure.unsupported
case .zellij: native = RemoteZellijCommands.create(name: name)
case .zmx: native = RemoteZmxCommands.create(name: name, token: token)
case .herdr: native = RemoteHerdrCommands.create(name: name)
}
let requireStartup = startupCommand?.isEmpty == false
let shell =
try environment.last(where: { $0.variable == "SHELL" }).map {
try RemoteMultiplexerCommands.quote($0.value)
} ?? "\"${SHELL:-/bin/sh}\""
let result = try await execute(
"""
export TERMIND_SHELL_LOCALE_SET="${LC_ALL+x}" TERMIND_SHELL_LOCALE="${LC_ALL-}"
\(kind.discovery)
\(RemoteMultiplexerCommands.utilities)
\(RemoteMultiplexerCommands.stateRoot(kind, create: true))
umask 077
lock="$state/\(name).lock"
mkdir "$lock" 2>/dev/null || exit 79
reserved=0; native_started=0
cleanup() {
code=$?
if [ "$reserved" = 1 ] && [ "$native_started" = 0 ]; then
rm -f "$meta/owner" "$meta/helper" "$meta/create-error"
rmdir "$meta" 2>/dev/null
fi
rmdir "$lock" 2>/dev/null
exit "$code"
}
trap cleanup EXIT
trap 'exit 78' HUP INT TERM
session_shell=\(shell)
\(requireStartup ? "case \"$session_shell\" in */bash|*/zsh|*/fish) [ -x \"$session_shell\" ] || exit 81 ;; *) exit 81 ;; esac" : "")
\(try RemoteShellStartup.install(
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, backend: kind.rawValue,
environment: environment))
meta="$state/\(name)"
# A reservation with no confirmed incarnation is not permission to adopt a same-named session.
mkdir "$meta" 2>/dev/null || exit 79
reserved=1
chmod 700 "$meta" || exit 78
printf '%s\\n' '\(token.uuidString)' > "$meta/owner" || exit 78
printf '%s\\n' "$helper" > "$meta/helper" || exit 78
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL="$session_shell"
\(native)
fp=$(fingerprint "$socket") || exit 79
printf '%s\\n' "$fp" > "$meta/identity" || exit 78
\(requireStartup ? """
attempt=0
while [ ! -f "$helper/started" ]; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 50 ]; then
# A slow or bypassed user rc must not run the snippet after reporting failure.
mkdir "$helper/startup-decision" 2>/dev/null && exit 81
[ -f "$helper/started" ] || exit 81
fi
sleep 0.1
done
[ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81
""" : "")
: > "$meta/ready"
printf 'TERMIND_CREATED\\n'
""")
try Self.check(result)
guard result.stdout == "TERMIND_CREATED\n",
let session = try await sessions().first(where: { $0.name == name }),
try RemoteMultiplexerIdentity.decode(session.identity, kind: kind).owner == token
else { throw RemoteSessionFailure.ownershipMismatch }
return session
}
func launch(_ reference: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
try validate(reference)
if kind == .tmux {
return .init(
command: try RemoteTmuxCommands.attach(reference), attachment: reference, created: created)
}
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
let attach: String
switch kind {
case .tmux: throw RemoteSessionFailure.unsupported
case .zellij: attach = "\"$exe\" attach \(name)"
case .zmx:
// attach is an upsert. If the daemon disappears after validation, its replacement must exit,
// not become an unrelated interactive shell (or replay the original startup command).
attach = "\"$exe\" attach \(name) /bin/sh -c 'exit 79'"
case .herdr: attach = "\"$exe\" --session \(name) client"
}
let noResurrection =
kind == .zellij
? """
# An attach command can otherwise resurrect a cached session after a disconnect race.
cache="/tmp/termind-zellij-attach-cache-$(id -u)"
private_dir "$cache" || exit 79
export XDG_CACHE_HOME="$cache"
""" : ""
let bind: String
if let helper = identity.helper {
bind = """
helper=\(try RemoteMultiplexerCommands.quote(helper))
tty=$(tty) || exit 79
key=$(printf '%s' "$tty" | od -An -v -tx1 | tr -d ' \\n')
printf '%s|%s\\n' "$$" "$(process_start "$$")" > "$helper/client-$key"
"""
} else {
bind = ""
}
return .init(
command: try RemoteMultiplexerCommands.guardIdentity(reference) + "\n" + noResurrection
+ "\n" + bind + "\nexec " + attach,
attachment: reference, created: created)
}
func terminate(_ reference: RemoteSessionReference) async throws {
try validate(reference)
guard reference.isManaged, kind != .herdr || reference.session.name != "default" else {
throw RemoteSessionFailure.ownershipMismatch
}
if kind == .tmux {
let result = try await execute(RemoteTmuxCommands.terminate(reference))
try Self.check(result)
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch }
return
}
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
if kind == .herdr, identity.directoryFingerprint == nil {
throw RemoteSessionFailure.ownershipMismatch
}
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
let stop: String
switch kind {
case .tmux: throw RemoteSessionFailure.unsupported
case .zellij: stop = "\"$exe\" kill-session \(name)"
case .zmx: stop = "\"$exe\" kill \(name) --force"
case .herdr: stop = "\"$exe\" session stop \(name) --json"
}
let result = try await execute(
"""
\(try RemoteMultiplexerCommands.guardIdentity(reference))
\(stop) >/dev/null || exit 78
attempt=0
while [ -S \(try RemoteMultiplexerCommands.quote(identity.socket)) ]; do
attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 79
sleep 0.1
done
\(kind == .herdr ? """
private_state || exit 79
[ "$(cat "$meta/owner")" = '\(reference.ownershipToken!.uuidString)' ] || exit 79
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
[ "$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(identity.root)))" = '\(identity.directoryFingerprint!)' ] || exit 79
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.socket)) ] || exit 79
"$exe" session delete \(name) --json >/dev/null || exit 78
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.root)) ] || exit 79
""" : "")
printf 'TERMIND_ENDED\\n'
""")
try Self.check(result)
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.invalidResponse }
}
}
@@ -0,0 +1,13 @@
import TermindApplication
import TermindDomain
extension RemoteSessionBackendRegistry {
public nonisolated static var live: Self {
Self(
Dictionary(
uniqueKeysWithValues: SessionPersistenceBackendID.supported.map {
id -> (SessionPersistenceBackendID, Factory) in
(id, { execute in RemoteMultiplexerService(backend: id, execute: execute) })
}))
}
}
@@ -76,22 +76,34 @@ nonisolated enum RemoteShellContextJournal {
fi
"""#
static func initialization(shell: String, token: String) -> String {
static func initialization(shell: String, token: String, backend: String? = nil) -> String {
let paneVariable: String?
switch backend {
case "zellij": paneVariable = "ZELLIJ_PANE_ID"
case "herdr": paneVariable = "HERDR_PANE_ID"
case "zmx": paneVariable = nil
default: paneVariable = "TMUX_PANE"
}
let tag = backend == nil ? "M1" : "M2"
let suffix = backend.map { "|\($0)" } ?? ""
if shell == "fish" {
return """
if set -q __termind_context_dir; return; end
set -gx TERMIND_CONTEXT_TOKEN '\(token)'
set -gx __termind_context_dir (command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX")
test -n "$__termind_context_dir"; or return
\(backend == nil ? "" : """
command env LC_ALL=C TZ=UTC ps -p "$fish_pid" -o lstart= | string trim > "$__termind_context_dir/process-start"
""")
set -l pane shell
if set -q TMUX_PANE; set pane "$TMUX_PANE"; end
\(paneVariable.map { "if set -q \($0); set pane \"$\($0)\"; end" } ?? "")
set -l history_name fish
if set -q fish_history; set history_name "$fish_history"; end
set -l history_base "$HOME/.local/share"
if set -q XDG_DATA_HOME; set history_base "$XDG_DATA_HOME"; end
set -l history_path ''
if test -n "$history_name"; set history_path "$history_base/fish/$history_name"_history; end
printf 'M1|%s|%s|%s|fish|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \
printf '\(tag)|%s|%s|%s|fish|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \
(command basename "$__termind_context_dir") (string escape --style=url -- "$HOME") "$fish_pid" \
(string escape --style=url -- "$history_path") > "$__termind_context_dir/meta"
function __termind_event
@@ -104,18 +116,22 @@ nonisolated enum RemoteShellContextJournal {
export TERMIND_CONTEXT_TOKEN='\(token)'
__termind_context_dir=$(command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") || return
export __termind_context_dir
\(backend == nil ? "" : """
command env LC_ALL=C TZ=UTC ps -p "$$" -o lstart= | sed 's/^ *//;s/ *$//' > "$__termind_context_dir/process-start"
""")
__termind_event() { command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" "$@"; }
__termind_home=$(printf '%s' "$HOME" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g')
__termind_history=${HISTFILE:-}
case "$__termind_history" in ''|/*) ;; *) __termind_history="$PWD/$__termind_history" ;; esac
__termind_history=$(printf '%s' "$__termind_history" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g')
printf 'M1|%s|%s|%s|\(shell)|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "${TMUX_PANE:-shell}" \
printf '\(tag)|%s|%s|%s|\(shell)|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "\(paneVariable.map { "${\($0):-shell}" } ?? "shell")" \
"${__termind_context_dir##*/}" "$__termind_home" "$$" "$__termind_history" > "$__termind_context_dir/meta"
"""
}
}
nonisolated struct RemoteShellContextRecord {
let backend: String
let token: String
let pane: String
let instance: String
@@ -136,8 +152,13 @@ nonisolated struct RemoteShellContextRecord {
guard lines.count >= 2 else { throw TerminalRuntimeError.invalidResponse }
let meta = lines[0].split(separator: "|", omittingEmptySubsequences: false).map(String.init)
let state = lines[1].split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard (7...8).contains(meta.count), meta[0] == "M1", UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil,
meta[2] == "shell" || Self.validPane(meta[2]),
let legacy = (7...8).contains(meta.count) && meta.first == "M1"
let modern = meta.count == 9 && meta.first == "M2"
guard legacy || modern else { throw TerminalRuntimeError.invalidResponse }
let backend = modern ? meta[8] : (meta[2] == "shell" ? "shell" : "tmux")
guard ["shell", "tmux", "zellij", "zmx", "herdr"].contains(backend),
UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil,
Self.validPane(meta[2], backend: backend),
meta[3].hasPrefix("ctx."),
meta[3].utf8.allSatisfy({
$0 == 46 || (48...57).contains($0)
@@ -148,6 +169,7 @@ nonisolated struct RemoteShellContextRecord {
["0", "1"].contains(state[2])
else { throw TerminalRuntimeError.invalidResponse }
token = meta[1]
self.backend = backend
pane = meta[2]
instance = meta[3]
shell = meta[4]
@@ -174,7 +196,7 @@ nonisolated struct RemoteShellContextRecord {
}
let home = try decode(meta[5])
self.home = home.hasPrefix("/") ? home : nil
let history = try meta.count == 8 ? decode(meta[7]) : ""
let history = try meta.count >= 8 ? decode(meta[7]) : ""
historyPath =
(history.isEmpty || history.hasPrefix("/"))
&& !history.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
@@ -214,6 +236,21 @@ nonisolated struct RemoteShellContextRecord {
value.hasPrefix("%") && value.count > 1 && value.dropFirst().utf8.allSatisfy { (48...57).contains($0) }
}
static func validPane(_ value: String, backend: String) -> Bool {
switch backend {
case "shell", "zmx": return value == "shell"
case "tmux": return validPane(value)
case "zellij": return !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
case "herdr":
return !value.isEmpty && value.utf8.count <= 128
&& value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|| [45, 58, 95].contains($0)
}
default: return false
}
}
func context(server: String, session: String, backend: String) -> TerminalShellContext {
TerminalShellContext(
identity: .init(backend: backend, server: server, session: session, pane: pane, shellInstance: instance),
@@ -1,17 +1,21 @@
import Foundation
import TermindDomain
/// Private, per-session startup files. Nothing is ever typed into an attached terminal.
nonisolated enum RemoteTmuxShellStartup {
nonisolated enum RemoteShellStartup {
static func install(
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false,
backend: String? = nil, environment: [HostEnvironmentVariable] = []
) throws -> String {
let eventToken = ordinary ? token : UUID()
let bootstrap = RemoteShellIntegrationBootstrap(token: eventToken.uuidString)
let startup = try RemoteTmuxCommands.quote(startupCommand ?? "")
let startup = try RemoteMultiplexerCommands.quote(startupCommand ?? "")
let reportsStartup = shellIntegration && startupCommand?.isEmpty == false
let posixStartup = """
if [ "${TERMIND_TMUX_FIRST:-}" = 1 ]; then
unset TERMIND_TMUX_FIRST
\(backend == nil ? "" : "mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null || return")
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
\(reportsStartup ? "__termind_startup_time=$(date +%s); __termind_event start \"$(__termind_encode \(startup))\"" : "")
eval \(startup)
\(reportsStartup ? "__termind_startup_status=$?; __termind_event finish \"$__termind_startup_status\" \"$(( ($(date +%s) - __termind_startup_time) * 1000 ))\"" : "")
@@ -20,6 +24,8 @@ nonisolated enum RemoteTmuxShellStartup {
let fishStartup = """
if test "$TERMIND_TMUX_FIRST" = 1
set -e TERMIND_TMUX_FIRST
\(backend == nil ? "" : "command mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null; or return")
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
\(reportsStartup ? "set -g __termind_startup_time (date +%s); __termind_event start (string escape --style=url -- \(startup))" : "")
eval \(startup)
\(reportsStartup ? "set -l command_status $status; set -l elapsed (math (date +%s) - $__termind_startup_time); __termind_event finish $command_status (math $elapsed '*' 1000)" : "")
@@ -27,29 +33,54 @@ nonisolated enum RemoteTmuxShellStartup {
"""
let bash =
(shellIntegration
? RemoteShellContextJournal.initialization(shell: "bash", token: eventToken.uuidString)
? RemoteShellContextJournal.initialization(
shell: "bash", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "bash", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + (shellIntegration ? "__termind_command_ready=0\n" : "") + posixStartup
+ (shellIntegration ? "\n__termind_report_prompt\n" : "")
let zsh =
(shellIntegration
? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString)
? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "zsh", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + posixStartup
let fish =
(shellIntegration
? RemoteShellContextJournal.initialization(shell: "fish", token: eventToken.uuidString)
? RemoteShellContextJournal.initialization(
shell: "fish", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "fish", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + fishStartup
let shell = """
#!/bin/sh
\(backend == nil ? "" : """
if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then
export LC_ALL="$TERMIND_SHELL_LOCALE"
else
unset LC_ALL
fi
unset TERMIND_SHELL_LOCALE_SET TERMIND_SHELL_LOCALE
""")
\(backend == "zellij" ? """
if [ "${TERMIND_SHELL_CACHE_HOME_SET:-}" = x ]; then
export XDG_CACHE_HOME="$TERMIND_SHELL_CACHE_HOME"
else
unset XDG_CACHE_HOME
fi
unset TERMIND_SHELL_CACHE_HOME_SET TERMIND_SHELL_CACHE_HOME
""" : "")
\(try RemoteMultiplexerCommands.environment(environment))
SHELL=${TERMIND_TMUX_SHELL:-${SHELL:-/bin/sh}}
export SHELL
if [ "$#" -gt 0 ]; then exec "$SHELL" "$@"; fi
unset __termind_context_dir
\(backend == nil ? "" : """
unset TERMIND_TMUX_FIRST
if mkdir "$TERMIND_TMUX_HELPERS/first-claimed" 2>/dev/null; then
export TERMIND_TMUX_FIRST=1
fi
""")
export TERMIND_CONTEXT_OSC=\(ordinary ? "1" : "0")
case "$SHELL" in
*/bash)
@@ -76,7 +107,7 @@ nonisolated enum RemoteTmuxShellStartup {
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n"
var files = [
"shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer,
"request-reader": RemoteShellContextJournal.requestReader,
"request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString,
]
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
files[name] = """
@@ -88,7 +119,7 @@ nonisolated enum RemoteTmuxShellStartup {
}
files[".zlogin", default: ""] += "\nexport ZDOTDIR=\"$TERMIND_USER_ZDOTDIR\"\n\(zsh)\n"
let writes = try files.keys.sorted().map { name in
"printf '%s\\n' \(try RemoteTmuxCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78"
"printf '%s\\n' \(try RemoteMultiplexerCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78"
}.joined(separator: "\n")
return """
umask 077
@@ -58,8 +58,7 @@ nonisolated struct RemoteTerminalContextFilter {
}
update.contexts.append(
.init(
status: context.promptReady ? .ordinary : .unknown,
active: context.promptReady ? context.identity : nil,
status: .ordinary, active: context.identity,
contexts: [context], events: record.events(for: context)))
} else {
update.contexts.append(.init(status: .unavailable))
@@ -80,4 +79,26 @@ nonisolated struct RemoteTerminalContextFilter {
buffer = Data(buffer[end...])
return update
}
static func needsObservation(_ update: TerminalRuntimeUpdate) -> Bool {
update.status != .ordinary
|| update.contexts.first(where: { $0.identity == update.active })?.promptReady != true
}
static func reconcileObservation(
_ observation: TerminalRuntimeUpdate, current: TerminalRuntimeUpdate?
) -> TerminalRuntimeUpdate {
// A negative multiplexer probe does not invalidate the authenticated outer
// shell's cwd. Keep polling for manual attach, without restoring prompt rights.
if observation.status == .unknown || observation.status == .unavailable,
let current, current.status == .ordinary,
let shell = current.contexts.first(where: { $0.identity == current.active }),
shell.identity.backend == "shell", !shell.promptReady
{
var retained = current
retained.events = []
return retained
}
return observation
}
}
@@ -6,55 +6,41 @@ nonisolated enum RemoteTerminalDraft {
static func command(
context: TerminalShellContext, terminal: String, helper: String?,
clientIdentity: String?, request: UUID, replacement: String?, original: String? = nil,
charset: RemoteCharset = .utf8
charset: RemoteCharset = .utf8, target: RemoteMultiplexerDraftTarget? = nil
) throws -> String {
let identity = context.identity
guard context.promptReady, ["bash", "zsh", "fish"].contains(context.shell),
identity.shellInstance.hasPrefix("ctx."),
identity.shellInstance.utf8.allSatisfy({
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 46
}), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true
}), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true,
original?.utf8.count ?? 0 <= 4096, original?.contains("\0") != true
else { throw TerminalRuntimeError.staleContext }
let root: String
let trigger: String
let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~"
if identity.backend == "tmux" {
let session = identity.session.split(separator: ":").map(String.init)
let client = clientIdentity?.split(separator: ":").map(String.init) ?? []
guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil,
UInt64(session[1]) != nil, UInt64(identity.server) != nil,
RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal,
UInt64(client[1]) != nil, UInt64(client[2]) != nil
else { throw TerminalRuntimeError.staleContext }
let target = try RemoteTmuxCommands.quote(session[0])
root = """
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
= '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79
helper=${helper#TERMIND_TMUX_HELPERS=}
"""
let guardFormat =
"#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}"
trigger = """
tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \
\(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79
"""
if let target {
guard RemoteMultiplexerKind(rawValue: identity.backend) != nil else {
throw TerminalRuntimeError.staleContext
}
root = target.root
trigger = target.trigger
} else if identity.backend == "tmux" {
let target = try RemoteTmuxRuntimeDialect.draftTarget(
context: context, terminal: terminal, clientIdentity: clientIdentity, replacing: replacement != nil)
root = target.root
trigger = target.trigger
} else {
guard identity.backend == "shell", let helper else { throw TerminalRuntimeError.staleContext }
root = "helper=\(try RemoteTmuxCommands.quote(helper))"
root = "helper=\(try RemoteMultiplexerCommands.quote(helper))"
// The worker sends the negotiated widget key after this request is installed.
trigger = "printf 'READY\\n'"
}
let bytes = try RemoteCharsetStreamConverter.encode(replacement ?? "", charset: charset)
guard bytes.count <= 4096 else { throw TerminalRuntimeError.staleContext }
let encoded = bytes.map { String(format: "%%%02X", $0) }.joined()
let baseline =
try original.map {
try RemoteCharsetStreamConverter.encode($0, charset: charset).map { String(format: "%%%02X", $0) }
.joined()
} ?? "-"
let baselineBytes = try original.map { try RemoteCharsetStreamConverter.encode($0, charset: charset) }
guard baselineBytes?.count ?? 0 <= 4096 else { throw TerminalRuntimeError.staleContext }
let baseline = baselineBytes?.map { String(format: "%%%02X", $0) }.joined() ?? "-"
return """
umask 077
\(root)
@@ -79,12 +65,12 @@ nonisolated enum RemoteTerminalDraft {
throw TerminalRuntimeError.staleContext
}
root = """
helper=$(tmux show-environment -t \(try RemoteTmuxCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
helper=${helper#TERMIND_TMUX_HELPERS=}
"""
} else {
guard let helper else { throw TerminalRuntimeError.staleContext }
root = "helper=\(try RemoteTmuxCommands.quote(helper))"
root = "helper=\(try RemoteMultiplexerCommands.quote(helper))"
}
return """
\(root)
@@ -1,94 +1,8 @@
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
/// Control operations run serially before the terminal channel occupies a server session slot.
public nonisolated struct RemoteTmuxService: RemoteSessionBackend, Sendable {
public typealias Executor = RemoteSessionCommandExecutor
private let execute: Executor
public var id: SessionPersistenceBackendID { .tmux }
public var capabilities: RemoteSessionCapabilities {
.init(windowCount: true, clientCount: true, shellIntegration: true)
}
public init(execute: @escaping Executor) { self.execute = execute }
public func validate(_ reference: RemoteSessionReference) throws {
guard reference.version == 1, reference.backend == id else { throw RemoteSessionFailure.unsupported }
_ = try TmuxSessionIdentity.decode(reference.session.identity)
}
public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
guard reference.version == 1, reference.backend == id,
reference.session.id == session.id,
let saved = try? TmuxSessionIdentity.decode(reference.session.identity),
let current = try? TmuxSessionIdentity.decode(session.identity),
saved.created == current.created, saved.serverPID == current.serverPID
else { return false }
return reference.ownershipToken.map { current.owner == $0.uuidString } ?? true
}
public func probe() async throws -> RemoteSessionCapabilities {
let version = try await execute("command -v tmux >/dev/null 2>&1 || exit 77; tmux -V")
if version.exitCode == 77 { throw RemoteSessionFailure.missing }
try Self.check(version)
try RemoteTmuxCommands.validateVersion(version.stdout)
return capabilities
}
public func sessions() async throws -> [RemoteSessionDescriptor] {
let result = try await execute(RemoteTmuxCommands.list)
try Self.check(result)
return try RemoteTmuxCommands.parseSessions(result.stdout)
}
public func create(
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = []
) async throws
-> RemoteSessionDescriptor
{
let result = try await execute(
RemoteTmuxCommands.create(
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration,
environment: environment))
if result.exitCode == 80 { throw RemoteSessionFailure.unsupported }
try Self.check(result)
let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout)
guard confirmed.count == 1, let session = confirmed.first,
try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString
else {
throw RemoteSessionFailure.ownershipMismatch
}
return session
}
public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
RemoteSessionLaunch(
command: try RemoteTmuxCommands.attach(attachment), attachment: attachment, created: created)
}
public func terminate(_ attachment: RemoteSessionReference) async throws {
guard attachment.isManaged else { throw RemoteSessionFailure.ownershipMismatch }
let result = try await execute(RemoteTmuxCommands.terminate(attachment))
if result.exitCode == 79 { throw RemoteSessionFailure.ownershipMismatch }
try Self.check(result)
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch }
}
private static func check(_ result: RemoteSessionCommandResult) throws {
guard !result.truncated else { throw RemoteSessionFailure.invalidResponse }
guard result.exitCode == 0 else {
throw RemoteSessionFailure.commandFailed(result.stderr)
}
}
}
extension RemoteSessionBackendRegistry {
public static var live: Self { Self([.tmux: { RemoteTmuxService(execute: $0) }]) }
}
private nonisolated struct TmuxSessionIdentity: Codable {
nonisolated struct TmuxSessionIdentity: Codable {
var version = 1
let created: String
let serverPID: String
@@ -103,8 +17,7 @@ private nonisolated struct TmuxSessionIdentity: Codable {
public nonisolated enum RemoteTmuxCommands {
public static func quote(_ value: String) throws -> String {
guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse }
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
try RemoteMultiplexerCommands.quote(value)
}
public static func validateVersion(_ output: String) throws {
@@ -194,10 +107,7 @@ public nonisolated enum RemoteTmuxCommands {
}
static func managedSessionName(token: UUID) -> String {
// Keep 64 bits of a one-way digest: short to display, stable on retry, not an ownership credential.
"termind-"
+ SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8)
.map { String(format: "%02x", $0) }.joined()
RemoteMultiplexerCommands.managedName(token)
}
public static func create(
@@ -205,7 +115,7 @@ public nonisolated enum RemoteTmuxCommands {
) throws -> String {
let owner = token.uuidString
let name = managedSessionName(token: token)
let wrapper = try RemoteTmuxShellStartup.install(
let wrapper = try RemoteShellStartup.install(
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration)
guard environment.allSatisfy({ $0.isValid && !$0.variable.hasPrefix("TERMIND_") && $0.variable != "TMUX" })
else {
@@ -2,15 +2,15 @@ import Foundation
import TermindApplication
import TermindDomain
public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter {
nonisolated struct RemoteTmuxRuntimeDialect: TerminalRuntimeAdapter {
private let execute: RemoteSessionCommandExecutor
private let charset: RemoteCharset
public init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
self.execute = execute
self.charset = charset
}
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
let result = try await execute(Self.command(terminal: terminal))
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
return try Self.parse(result.stdout, terminal: terminal, charset: charset)
@@ -166,3 +166,35 @@ public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter {
status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events)
}
}
extension RemoteTmuxRuntimeDialect {
static func draftTarget(context: TerminalShellContext, terminal: String, clientIdentity: String?, replacing: Bool)
throws -> RemoteMultiplexerDraftTarget
{
let identity = context.identity
let key = replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~"
let session = identity.session.split(separator: ":").map(String.init)
let client = clientIdentity?.split(separator: ":").map(String.init) ?? []
guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil,
UInt64(session[1]) != nil, UInt64(identity.server) != nil,
RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal,
UInt64(client[1]) != nil, UInt64(client[2]) != nil
else { throw TerminalRuntimeError.staleContext }
let target = try RemoteTmuxCommands.quote(session[0])
let root = """
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
= '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79
helper=${helper#TERMIND_TMUX_HELPERS=}
"""
let guardFormat =
"#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}"
let trigger = """
tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \
\(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79
"""
return .init(root: root, trigger: trigger)
}
}
@@ -0,0 +1,85 @@
import Foundation
import TermindApplication
import TermindDomain
nonisolated enum RemoteZellijCommands {
static let userRoot = #"""
if [ -n "${ZELLIJ_SOCKET_DIR:-}" ]; then root=$ZELLIJ_SOCKET_DIR
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zellij"
else root="${TMPDIR:-/tmp}/zellij-$(id -u)"; fi
"""#
static var list: String {
"""
\(RemoteMultiplexerKind.zellij.discovery)
\(RemoteMultiplexerCommands.utilities)
\(userRoot)
user_root=$root
for scope in user managed; do
if [ "$scope" = user ]; then root=$user_root; else root="/tmp/termind-zellij-$(id -u)"; fi
printf 'ROOT|%s|%s\\n' "$scope" "$(hex "$root")"
if [ -d "$root" ]; then
export ZELLIJ_SOCKET_DIR=$root
listing=$("$exe" list-sessions --short --no-formatting 2>&1)
code=$?
if [ "$code" = 0 ]; then
printf '%s\\n' "$listing" | while IFS= read -r name; do
case "$name" in ''|*[!a-zA-Z0-9_.-]*|.|..) exit 78 ;; esac
# list-sessions also includes serialized, exited sessions. Never offer those as live.
socket=$(find "$root" -type s -name "$name" -print 2>/dev/null) || exit 78
[ -S "$socket" ] && [ ! -L "$socket" ] || continue
"$exe" --session "$name" action list-panes --json >/dev/null 2>&1 || continue
printf '%s\\n' "$name"
done || exit 78
elif [ "$listing" != 'No active zellij sessions found.' ]; then printf '%s\\n' "$listing" >&2; exit 78; fi
fi
printf 'END\\n'
done
"""
}
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
guard text.hasSuffix("\n") else { throw RemoteSessionFailure.invalidResponse }
var result: [RemoteMultiplexerCandidate] = []
var root: String?
var isolated = false
var scopes: Set<String> = []
for line in text.split(separator: "\n").map(String.init) {
if line.hasPrefix("ROOT|") {
let fields = line.components(separatedBy: "|")
guard root == nil, fields.count == 3, ["user", "managed"].contains(fields[1]),
scopes.insert(fields[1]).inserted
else { throw RemoteSessionFailure.invalidResponse }
root = try RemoteMultiplexerCommands.unhex(fields[2])
isolated = fields[1] == "managed"
} else if line == "END" {
guard root != nil else { throw RemoteSessionFailure.invalidResponse }
root = nil
} else {
guard let root, RemoteMultiplexerCommands.validName(line) else {
throw RemoteSessionFailure.invalidResponse
}
result.append(.init(name: line, root: root, socket: nil, isolated: isolated))
}
}
guard root == nil, scopes == ["user", "managed"] else { throw RemoteSessionFailure.invalidResponse }
return result
}
static func create(name: String) -> String {
"""
root="/tmp/termind-zellij-$(id -u)"
private_dir "$root" || exit 78
export ZELLIJ_SOCKET_DIR=$root
export TERMIND_SHELL_CACHE_HOME_SET="${XDG_CACHE_HOME+x}" TERMIND_SHELL_CACHE_HOME="${XDG_CACHE_HOME-}"
private_dir "$root/cache" || exit 78
export XDG_CACHE_HOME="$root/cache"
existing=$(find "$root" -type s -name '\(name)' -print) || exit 78
[ -z "$existing" ] || exit 79
native_started=1
"$exe" --layout-string 'layout { pane; }' attach --create-background '\(name)' options --default-shell "$helper/shell" \
--session-serialization false >/dev/null || exit 78
socket=$(find "$root" -type s -name '\(name)' -print) || exit 78
"""
}
}
@@ -0,0 +1,78 @@
import Foundation
import TermindApplication
import TermindDomain
nonisolated enum RemoteZmxCommands {
static let root = #"""
if [ -n "${ZMX_DIR:-}" ]; then root=$ZMX_DIR
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zmx"
else root="${TMPDIR:-/tmp}/zmx-$(id -u)"; fi
"""#
static var list: String {
"""
\(RemoteMultiplexerKind.zmx.discovery)
\(RemoteMultiplexerCommands.utilities)
\(root)
printf 'ROOT|%s\\n' "$(hex "$root")"
"$exe" list || exit 78
printf 'END\\n'
"""
}
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
var lines = text.split(separator: "\n").map(String.init)
guard text.hasSuffix("\n"), lines.count >= 2, lines[0].hasPrefix("ROOT|"), lines.last == "END" else {
throw RemoteSessionFailure.invalidResponse
}
lines.removeLast()
let root = try RemoteMultiplexerCommands.unhex(String(lines.removeFirst().dropFirst(5)))
guard RemoteMultiplexerCommands.validPath(root) else { throw RemoteSessionFailure.invalidResponse }
return try lines.map { line in
var fields: [String: String] = [:]
for part in line.split(separator: "\t") {
guard let separator = part.firstIndex(of: "=") else { throw RemoteSessionFailure.invalidResponse }
let key = String(part[..<separator])
guard fields[key] == nil else { throw RemoteSessionFailure.invalidResponse }
fields[key] = String(part[part.index(after: separator)...])
}
guard let name = fields["name"], RemoteMultiplexerCommands.validName(name),
let pid = fields["pid"], UInt64(pid).map({ $0 > 1 }) == true,
let created = fields["created"], UInt64(created) != nil,
let clients = fields["clients"].flatMap(Int.init), clients >= 0, fields["err"] == nil
else { throw RemoteSessionFailure.invalidResponse }
return .init(
name: name, root: root, socket: root + "/" + name, process: pid, created: created, clients: clients)
}
}
static func create(name: String, token: UUID) -> String {
"""
\(root)
export ZMX_DIR=$root
socket="$root/\(name)"
[ ! -e "$socket" ] && [ ! -L "$socket" ] || exit 79
native_started=1
"$exe" attach '\(name)' "$helper/shell" </dev/null >/dev/null 2>"$meta/create-error" || exit 78
"$exe" set '\(name)' 'termind_owner=\(token.uuidString)' >/dev/null || exit 78
native=$(\(nativeIdentity(name: name))) || exit 79
[ -n "$native" ] || exit 79
printf '%s\\n' "$native" > "$meta/native"
"""
}
// The listed PID is the PTY child. Labels or commands containing "pid=" are not identity fields.
static func nativeIdentity(name: String) -> String {
"""
"$exe" list | awk -F '\\t' -v target='\(name)' '
{ name=""; pid=""; created=""; count=0
for(i=1;i<=NF;i++) {
if ($i ~ /^name=/) {name=substr($i,6); count++}
if ($i ~ /^pid=[0-9]+$/) {pid=substr($i,5); count++}
if ($i ~ /^created=[0-9]+$/) {created=substr($i,9); count++}
}
if(name==target && count==3 && pid!="" && created!="") print pid ":" created
}'
"""
}
}
@@ -969,6 +969,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private var runtimeTerminal: String?
private var runtimeRevision: UInt64 = 0
private var runtimeEnabled = false
private var runtimeAttachment: RemoteSessionReference?
private var runtimeWantsObservation = false
private var runtimeControlUnavailable = false
private var runtimeHelper: String?
@@ -1512,6 +1513,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
let executor = try await sessionCommandExecutor()
let launch = try await preparation(executor)
try Task.checkCancellation()
try await queue.perform { [self] in
guard ptyResize.generation == generation else { throw CancellationError() }
runtimeAttachment = launch?.runtimeAttachment
runtimeHelper = try RemoteMultiplexerRuntimeAdapter.helper(for: runtimeAttachment)
}
if shellIntegrationEnabled {
var probe = RemoteShellIntegrationBootstrap(charset: charset)
if let result = try? await executor(String(decoding: probe.startCommand, as: UTF8.self)),
@@ -1529,7 +1535,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
var startupCommand = launch?.command
if launch == nil, shellIntegrationEnabled {
do {
let install = try RemoteTmuxShellStartup.install(
let install = try RemoteShellStartup.install(
token: runtimeToken, startupCommand: nil, shellIntegration: true, ordinary: true)
let result = try await executor(install + "\nprintf '%s\\n' \"$helper\"\n")
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
@@ -1541,7 +1547,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
})
else { throw TerminalRuntimeError.invalidResponse }
startupCommand =
"export TERMIND_TMUX_HELPERS=\(try RemoteTmuxCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
await queue.perform { self.runtimeHelper = helper }
} catch {
try Task.checkCancellation()
@@ -1554,7 +1560,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
"printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original
}
let command = try startupCommand.map {
SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteTmuxCommands.quote($0))).utf8))
SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteMultiplexerCommands.quote($0))).utf8))
}
let runtimeIsEnabled = usesRuntime
try await queue.perform { [self] in
@@ -2115,7 +2121,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
try Task.checkCancellation()
let execute = try await sessionCommandExecutor(
runtime: true, expectedInput: inputRevision, activation: request)
try await RemoteTmuxRuntimeAdapter(execute: execute).refreshSize(of: request.client)
try await RemoteMultiplexerRuntimeAdapter(execute: execute).refreshSize(of: request.client)
} catch {
// Optional sizing assistance must not disconnect a usable terminal or inject a fallback key.
}
@@ -2184,7 +2190,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
for context in update.contexts {
runtimeRevision &+= 1
runtimeWantsObservation = context.status != .ordinary
runtimeWantsObservation = RemoteTerminalContextFilter.needsObservation(context)
publishRuntime(context)
if runtimeWantsObservation { startRuntimeObservation() }
}
@@ -2221,23 +2227,25 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(delay))
guard !Task.isCancelled else { break }
let request = try? await queue.perform { [self] () -> (String, UInt64, RemoteCharset)? in
let request = try? await queue.perform {
[self] () -> (String, UInt64, RemoteCharset, RemoteSessionReference?)? in
guard runtimeEnabled, runtimeWantsObservation, ptyResize.generation == generation,
!runtimeControlUnavailable, let terminal = runtimeTerminal
else { return nil }
return (terminal, runtimeRevision, charset)
return (terminal, runtimeRevision, charset, runtimeAttachment)
}
guard let request else { continue }
do {
let adapter = RemoteTmuxRuntimeAdapter(
execute: try await sessionCommandExecutor(runtime: true), charset: request.2)
let adapter = RemoteMultiplexerRuntimeAdapter(
attachment: request.3, execute: try await sessionCommandExecutor(runtime: true),
charset: request.2)
let update = try await adapter.inspect(terminal: request.0)
try Task.checkCancellation()
await queue.perform { [self] in
guard ptyResize.generation == generation, runtimeRevision == request.1,
runtimeWantsObservation
else { return }
publishRuntime(update)
publishRuntimeObservation(update)
}
delay = update.status == .multiplexer ? 750 : min(delay * 2, 5000)
} catch {
@@ -2246,7 +2254,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
guard ptyResize.generation == generation, runtimeRevision == request.1,
runtimeWantsObservation
else { return }
publishRuntime(.init(status: .unavailable))
publishRuntimeObservation(.init(status: .unavailable))
}
delay = min(delay * 2, 5000)
}
@@ -2254,6 +2262,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
}
private func publishRuntimeObservation(_ update: TerminalRuntimeUpdate) {
publishRuntime(RemoteTerminalContextFilter.reconcileObservation(update, current: latestRuntimeUpdate))
}
private func publishRuntime(_ proposed: TerminalRuntimeUpdate) {
var update = proposed
if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false {
@@ -2281,12 +2293,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
guard let terminal = runtimeTerminal, latestRuntimeUpdate?.active == expected.identity else {
throw TerminalRuntimeError.staleContext
}
return (terminal, runtimeInputRevision, ptyResize.generation, charset)
return (terminal, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
}
if expected.identity.backend != "shell" {
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.1)
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.3).inspect(
terminal: state.0)
let update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.4, execute: execute, charset: state.3
).inspect(terminal: state.0)
guard update.active == expected.identity,
update.contexts.first(where: { $0.identity == expected.identity }) == expected
else { throw TerminalRuntimeError.staleContext }
@@ -2310,14 +2323,15 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
.capabilities.contains(replacement == nil ? .readDraft : .replaceDraft) == true
else { throw TerminalRuntimeError.staleContext }
pendingRuntimeDraft = request
return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset)
return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
}
do {
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.2)
let update: TerminalRuntimeUpdate?
if expected.identity.backend == "tmux" {
update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.4).inspect(
terminal: state.0)
if expected.identity.backend != "shell" {
update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.5, execute: execute, charset: state.4
).inspect(terminal: state.0)
guard update?.active == expected.identity,
update?.contexts.first(where: { $0.identity == expected.identity }) == expected
else { throw TerminalRuntimeError.staleContext }
@@ -2325,12 +2339,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
update = nil
}
try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3)
var target: RemoteMultiplexerDraftTarget?
if expected.identity.backend != "shell" {
target = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.5, execute: execute, charset: state.4
).draftTarget(context: expected, terminal: state.0, replacing: replacement != nil)
}
let command = try RemoteTerminalDraft.command(
context: expected, terminal: state.0, helper: state.1,
clientIdentity: update?.clientIdentity, request: request, replacement: replacement,
original: try await queue.perform { [self] in
replacement != nil && runtimeDraftBaseline?.0 == expected.identity ? runtimeDraftBaseline?.1 : nil
}, charset: state.4)
}, charset: state.4, target: target)
let started = try await execute(command)
guard started.exitCode == 0, !started.truncated else { throw TerminalRuntimeError.staleContext }
if expected.identity.backend == "shell" {
@@ -2496,6 +2516,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
runtimeTask = nil
runtimeTerminal = nil
runtimeEnabled = false
runtimeAttachment = nil
allowsDefaultHistorySource = true
runtimeWantsObservation = false
runtimeControlUnavailable = false
@@ -2698,12 +2719,14 @@ nonisolated extension SSHPTYChannelWorker {
guard matchesHistoryContext(expected), let terminal = runtimeTerminal else {
throw TerminalRuntimeError.staleContext
}
return (terminal, ptyResize.generation, charset)
return (terminal, ptyResize.generation, charset, runtimeAttachment)
}
if expected.identity.backend != "shell" {
let execute = try await sessionCommandExecutor(runtime: true)
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.2)
.inspect(terminal: state.0)
let update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.3, execute: execute, charset: state.2
)
.inspect(terminal: state.0)
guard update.status == .multiplexer, update.active == expected.identity,
update.contexts.first(where: { $0.identity == expected.identity })?.historySource
== expected.historySource