steipete--codexbar
1198 行
45 KiB
Swift
1198 行
45 KiB
Swift
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#endif
|
|
import Foundation
|
|
|
|
private enum TTYCommandRunnerActiveProcessRegistry {
|
|
private static let condition = NSCondition()
|
|
private nonisolated(unsafe) static var processes: [pid_t: ProcessInfo] = [:]
|
|
private nonisolated(unsafe) static var isShuttingDown = false
|
|
private nonisolated(unsafe) static var launchesInProgress = 0
|
|
|
|
private struct ProcessInfo {
|
|
let binary: String
|
|
var processGroup: pid_t?
|
|
}
|
|
|
|
@discardableResult
|
|
static func register(pid: pid_t, binary: String) -> Bool {
|
|
guard pid > 0 else { return false }
|
|
self.condition.lock()
|
|
defer { self.condition.unlock() }
|
|
guard !self.isShuttingDown else { return false }
|
|
self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil)
|
|
return true
|
|
}
|
|
|
|
static func beginLaunch() -> Bool {
|
|
self.condition.lock()
|
|
defer { self.condition.unlock() }
|
|
guard !self.isShuttingDown else { return false }
|
|
self.launchesInProgress += 1
|
|
return true
|
|
}
|
|
|
|
static func endLaunch() {
|
|
self.condition.lock()
|
|
self.launchesInProgress = max(0, self.launchesInProgress - 1)
|
|
if self.launchesInProgress == 0 {
|
|
self.condition.broadcast()
|
|
}
|
|
self.condition.unlock()
|
|
}
|
|
|
|
static func updateProcessGroup(pid: pid_t, processGroup: pid_t?) {
|
|
guard pid > 0 else { return }
|
|
self.condition.lock()
|
|
guard var existing = self.processes[pid] else {
|
|
self.condition.unlock()
|
|
return
|
|
}
|
|
existing.processGroup = processGroup
|
|
self.processes[pid] = existing
|
|
self.condition.unlock()
|
|
}
|
|
|
|
static func unregister(pid: pid_t) {
|
|
guard pid > 0 else { return }
|
|
self.condition.lock()
|
|
self.processes.removeValue(forKey: pid)
|
|
self.condition.unlock()
|
|
}
|
|
|
|
static func drainForShutdown(
|
|
onFenceSet: (() -> Void)? = nil)
|
|
-> [(pid: pid_t, binary: String, processGroup: pid_t?)]
|
|
{
|
|
self.condition.lock()
|
|
self.isShuttingDown = true
|
|
onFenceSet?()
|
|
while self.launchesInProgress > 0 {
|
|
self.condition.wait()
|
|
}
|
|
let drained = self.processes.map {
|
|
(pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup)
|
|
}
|
|
self.processes.removeAll()
|
|
self.condition.unlock()
|
|
return drained
|
|
}
|
|
|
|
static func reset() {
|
|
self.condition.lock()
|
|
self.processes.removeAll()
|
|
self.isShuttingDown = false
|
|
self.launchesInProgress = 0
|
|
self.condition.broadcast()
|
|
self.condition.unlock()
|
|
}
|
|
|
|
static func count() -> Int {
|
|
self.condition.lock()
|
|
let count = self.processes.count
|
|
self.condition.unlock()
|
|
return count
|
|
}
|
|
|
|
static func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {
|
|
guard pid > 0 else { return }
|
|
self.condition.lock()
|
|
self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup)
|
|
self.condition.unlock()
|
|
}
|
|
}
|
|
|
|
enum TTYProcessTreeTerminator {
|
|
struct ProcessIdentity: Hashable {
|
|
let pid: pid_t
|
|
let startToken: UInt64
|
|
}
|
|
|
|
static func descendantPIDs(
|
|
of rootPID: pid_t,
|
|
childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:)) -> [pid_t]
|
|
{
|
|
guard rootPID > 0 else { return [] }
|
|
|
|
var seen: Set<pid_t> = [rootPID]
|
|
var pending = childResolver(rootPID)
|
|
var descendants: [pid_t] = []
|
|
|
|
while let pid = pending.popLast() {
|
|
guard pid > 0, seen.insert(pid).inserted else { continue }
|
|
descendants.append(pid)
|
|
pending.append(contentsOf: childResolver(pid))
|
|
}
|
|
|
|
return descendants
|
|
}
|
|
|
|
static func currentChildPIDs(of parentPID: pid_t) -> [pid_t] {
|
|
guard parentPID > 0 else { return [] }
|
|
|
|
#if canImport(Darwin)
|
|
var pids = [pid_t](repeating: 0, count: 128)
|
|
let byteCount = Int32(pids.count * MemoryLayout<pid_t>.stride)
|
|
let childCount = proc_listchildpids(parentPID, &pids, byteCount)
|
|
guard childCount > 0 else { return [] }
|
|
return Array(pids.prefix(min(Int(childCount), pids.count))).filter { $0 > 0 }
|
|
#else
|
|
let taskPath = "/proc/\(parentPID)/task"
|
|
guard let taskIDs = try? FileManager.default.contentsOfDirectory(atPath: taskPath) else { return [] }
|
|
|
|
var children: Set<pid_t> = []
|
|
for taskID in taskIDs {
|
|
let childrenPath = "\(taskPath)/\(taskID)/children"
|
|
guard let text = try? String(contentsOfFile: childrenPath, encoding: .utf8) else { continue }
|
|
children.formUnion(text.split(whereSeparator: \.isWhitespace).compactMap { pid_t($0) })
|
|
}
|
|
return children.sorted()
|
|
#endif
|
|
}
|
|
|
|
static func processIdentity(for pid: pid_t) -> ProcessIdentity? {
|
|
guard pid > 0 else { return nil }
|
|
|
|
#if canImport(Darwin)
|
|
var info = proc_bsdinfo()
|
|
let size = proc_pidinfo(
|
|
pid,
|
|
PROC_PIDTBSDINFO,
|
|
0,
|
|
&info,
|
|
Int32(MemoryLayout<proc_bsdinfo>.stride))
|
|
guard size == Int32(MemoryLayout<proc_bsdinfo>.stride) else { return nil }
|
|
let startToken = UInt64(info.pbi_start_tvsec) * 1_000_000 + UInt64(info.pbi_start_tvusec)
|
|
return ProcessIdentity(pid: pid, startToken: startToken)
|
|
#else
|
|
guard let stat = try? String(contentsOfFile: "/proc/\(pid)/stat", encoding: .utf8),
|
|
let commandEnd = stat.lastIndex(of: ")")
|
|
else {
|
|
return nil
|
|
}
|
|
let fields = stat[stat.index(after: commandEnd)...].split(whereSeparator: \.isWhitespace)
|
|
guard fields.count > 19, let startToken = UInt64(fields[19]) else { return nil }
|
|
return ProcessIdentity(pid: pid, startToken: startToken)
|
|
#endif
|
|
}
|
|
|
|
static func isCurrent(_ identity: ProcessIdentity) -> Bool {
|
|
self.processIdentity(for: identity.pid) == identity
|
|
}
|
|
|
|
static func terminateProcessTree(
|
|
rootPID: pid_t,
|
|
processGroup: pid_t?,
|
|
signal: Int32,
|
|
knownDescendants: [pid_t] = [],
|
|
childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:),
|
|
signalSender: (pid_t, Int32) -> Void = { kill($0, $1) })
|
|
{
|
|
guard rootPID > 0 else { return }
|
|
|
|
var seen: Set<pid_t> = [rootPID]
|
|
let descendants = knownDescendants + self.descendantPIDs(of: rootPID, childResolver: childResolver)
|
|
for pid in descendants where pid > 0 && seen.insert(pid).inserted {
|
|
signalSender(pid, signal)
|
|
}
|
|
if let processGroup {
|
|
signalSender(-processGroup, signal)
|
|
}
|
|
signalSender(rootPID, signal)
|
|
}
|
|
}
|
|
|
|
/// Executes an interactive CLI inside a pseudo-terminal and returns all captured text.
|
|
/// Keeps it minimal so we can reuse for Codex and Claude without tmux.
|
|
public struct TTYCommandRunner {
|
|
private static let log = CodexBarLog.logger(LogCategories.ttyRunner)
|
|
|
|
public struct Result: Sendable {
|
|
public enum Completion: Sendable, Equatable {
|
|
case processExited(status: Int32)
|
|
case idleTimeout
|
|
case outputCondition
|
|
case deadlineExceeded
|
|
}
|
|
|
|
public let text: String
|
|
public let completion: Completion
|
|
}
|
|
|
|
public struct Options: Sendable {
|
|
public var rows: UInt16 = 50
|
|
public var cols: UInt16 = 160
|
|
public var timeout: TimeInterval = 20.0
|
|
/// Stop early once output has been idle for this long (only for non-Codex flows).
|
|
/// Useful for interactive TUIs that render once and then wait for input indefinitely.
|
|
public var idleTimeout: TimeInterval?
|
|
public var workingDirectory: URL?
|
|
public var extraArgs: [String] = []
|
|
public var baseEnvironment: [String: String]?
|
|
public var initialDelay: TimeInterval = 0.4
|
|
public var sendEnterEvery: TimeInterval?
|
|
public var sendOnSubstrings: [String: String]
|
|
public var stopOnURL: Bool
|
|
public var stopOnSubstrings: [String]
|
|
public var settleAfterStop: TimeInterval
|
|
public var forceCodexStatusMode: Bool
|
|
public var useClaudeProbeWorkingDirectory: Bool
|
|
public var returnOnEmptyProcessExit: Bool
|
|
public var cancellationCheck: @Sendable () -> Bool
|
|
|
|
public init(
|
|
rows: UInt16 = 50,
|
|
cols: UInt16 = 160,
|
|
timeout: TimeInterval = 20.0,
|
|
idleTimeout: TimeInterval? = nil,
|
|
workingDirectory: URL? = nil,
|
|
extraArgs: [String] = [],
|
|
baseEnvironment: [String: String]? = nil,
|
|
initialDelay: TimeInterval = 0.4,
|
|
sendEnterEvery: TimeInterval? = nil,
|
|
sendOnSubstrings: [String: String] = [:],
|
|
stopOnURL: Bool = false,
|
|
stopOnSubstrings: [String] = [],
|
|
settleAfterStop: TimeInterval = 0.25,
|
|
forceCodexStatusMode: Bool = false,
|
|
useClaudeProbeWorkingDirectory: Bool = false,
|
|
returnOnEmptyProcessExit: Bool = false,
|
|
cancellationCheck: @escaping @Sendable () -> Bool = { Task<Never, Never>.isCancelled })
|
|
{
|
|
self.rows = rows
|
|
self.cols = cols
|
|
self.timeout = timeout
|
|
self.idleTimeout = idleTimeout
|
|
self.workingDirectory = workingDirectory
|
|
self.extraArgs = extraArgs
|
|
self.baseEnvironment = baseEnvironment
|
|
self.initialDelay = initialDelay
|
|
self.sendEnterEvery = sendEnterEvery
|
|
self.sendOnSubstrings = sendOnSubstrings
|
|
self.stopOnURL = stopOnURL
|
|
self.stopOnSubstrings = stopOnSubstrings
|
|
self.settleAfterStop = settleAfterStop
|
|
self.forceCodexStatusMode = forceCodexStatusMode
|
|
self.useClaudeProbeWorkingDirectory = useClaudeProbeWorkingDirectory
|
|
self.returnOnEmptyProcessExit = returnOnEmptyProcessExit
|
|
self.cancellationCheck = cancellationCheck
|
|
}
|
|
}
|
|
|
|
public enum Error: Swift.Error, LocalizedError, Sendable {
|
|
case binaryNotFound(String)
|
|
case launchFailed(String)
|
|
case timedOut
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case let .binaryNotFound(bin):
|
|
"Missing CLI '\(bin)'. Install it (e.g. npm i -g @openai/codex) or add it to PATH."
|
|
case let .launchFailed(msg): "Failed to launch process: \(msg)"
|
|
case .timedOut: "PTY command timed out."
|
|
}
|
|
}
|
|
}
|
|
|
|
public init() {}
|
|
|
|
public static func terminateActiveProcessesForAppShutdown() {
|
|
let targets = TTYCommandRunnerActiveProcessRegistry.drainForShutdown()
|
|
guard !targets.isEmpty else { return }
|
|
|
|
let resolvedTargets = self.resolveShutdownTargets(
|
|
targets,
|
|
hostProcessGroup: getpgrp(),
|
|
groupResolver: { getpgid($0) })
|
|
|
|
for target in resolvedTargets where target.pid > 0 {
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: target.pid,
|
|
processGroup: target.processGroup,
|
|
signal: SIGTERM)
|
|
}
|
|
|
|
for target in resolvedTargets where target.pid > 0 {
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: target.pid,
|
|
processGroup: target.processGroup,
|
|
signal: SIGKILL)
|
|
}
|
|
}
|
|
|
|
private static func resolveShutdownTargets(
|
|
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
|
|
hostProcessGroup: pid_t,
|
|
groupResolver: (pid_t) -> pid_t) -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
|
|
{
|
|
var resolvedTargets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = []
|
|
resolvedTargets.reserveCapacity(targets.count)
|
|
|
|
for target in targets {
|
|
var resolvedGroup = target.processGroup
|
|
if resolvedGroup == nil {
|
|
let pgid = groupResolver(target.pid)
|
|
if pgid > 0, pgid != hostProcessGroup {
|
|
resolvedGroup = pgid
|
|
}
|
|
} else if resolvedGroup == hostProcessGroup {
|
|
resolvedGroup = nil
|
|
}
|
|
|
|
resolvedTargets.append((pid: target.pid, binary: target.binary, processGroup: resolvedGroup))
|
|
}
|
|
return resolvedTargets
|
|
}
|
|
|
|
struct RollingBuffer {
|
|
private let maxNeedle: Int
|
|
private var tail = Data()
|
|
|
|
init(maxNeedle: Int) {
|
|
self.maxNeedle = max(0, maxNeedle)
|
|
}
|
|
|
|
mutating func append(_ data: Data) -> Data {
|
|
guard !data.isEmpty else { return Data() }
|
|
|
|
var combined = Data()
|
|
combined.reserveCapacity(self.tail.count + data.count)
|
|
combined.append(self.tail)
|
|
combined.append(data)
|
|
|
|
if self.maxNeedle > 1 {
|
|
if combined.count >= self.maxNeedle - 1 {
|
|
self.tail = combined.suffix(self.maxNeedle - 1)
|
|
} else {
|
|
self.tail = combined
|
|
}
|
|
} else {
|
|
self.tail.removeAll(keepingCapacity: true)
|
|
}
|
|
|
|
return combined
|
|
}
|
|
|
|
mutating func reset() {
|
|
self.tail.removeAll(keepingCapacity: true)
|
|
}
|
|
}
|
|
|
|
enum DrainReadResult {
|
|
case data(Data)
|
|
case wouldBlock
|
|
case closed
|
|
}
|
|
|
|
static func lowercasedASCII(_ data: Data) -> Data {
|
|
guard !data.isEmpty else { return data }
|
|
var out = Data(count: data.count)
|
|
out.withUnsafeMutableBytes { dest in
|
|
data.withUnsafeBytes { source in
|
|
let src = source.bindMemory(to: UInt8.self)
|
|
let dst = dest.bindMemory(to: UInt8.self)
|
|
for idx in 0..<src.count {
|
|
var byte = src[idx]
|
|
if byte >= 65, byte <= 90 { byte += 32 }
|
|
dst[idx] = byte
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
static func drainRemainingOutput(
|
|
until drainDeadline: Date,
|
|
readChunk: () -> DrainReadResult,
|
|
processChunk: (Data) -> Void,
|
|
sleep: (UInt32) -> Void = { usleep($0) })
|
|
{
|
|
while Date() < drainDeadline {
|
|
switch readChunk() {
|
|
case let .data(newData):
|
|
processChunk(newData)
|
|
case .wouldBlock:
|
|
sleep(20000)
|
|
case .closed:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
static func drainReadResult(for data: Data, terminalRead: Int, errno err: Int32) -> DrainReadResult {
|
|
if !data.isEmpty { return .data(data) }
|
|
|
|
if terminalRead == 0 {
|
|
return .closed
|
|
}
|
|
|
|
if terminalRead < 0 {
|
|
if err == EAGAIN || err == EWOULDBLOCK || err == EINTR {
|
|
return .wouldBlock
|
|
}
|
|
if err == EIO {
|
|
return .closed
|
|
}
|
|
}
|
|
|
|
return .closed
|
|
}
|
|
|
|
static func locateBundledHelper(_ name: String) -> String? {
|
|
let fm = FileManager.default
|
|
|
|
func isExecutable(_ path: String) -> Bool {
|
|
fm.isExecutableFile(atPath: path)
|
|
}
|
|
|
|
if let override = ProcessInfo.processInfo.environment["CODEXBAR_HELPER_\(name.uppercased())"],
|
|
isExecutable(override)
|
|
{
|
|
return override
|
|
}
|
|
|
|
func candidate(inAppBundleURL appURL: URL) -> String? {
|
|
let path = appURL
|
|
.appendingPathComponent("Contents", isDirectory: true)
|
|
.appendingPathComponent("Helpers", isDirectory: true)
|
|
.appendingPathComponent(name, isDirectory: false)
|
|
.path
|
|
return isExecutable(path) ? path : nil
|
|
}
|
|
|
|
let mainURL = Bundle.main.bundleURL
|
|
if mainURL.pathExtension == "app", let found = candidate(inAppBundleURL: mainURL) { return found }
|
|
|
|
if let argv0 = CommandLine.arguments.first {
|
|
var url = URL(fileURLWithPath: argv0)
|
|
if !argv0.hasPrefix("/") {
|
|
url = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(argv0)
|
|
}
|
|
var probe = url
|
|
for _ in 0..<6 {
|
|
let parent = probe.deletingLastPathComponent()
|
|
if parent.pathExtension == "app", let found = candidate(inAppBundleURL: parent) { return found }
|
|
if parent.path == probe.path { break }
|
|
probe = parent
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// swiftlint:disable function_body_length
|
|
// swiftlint:disable:next cyclomatic_complexity
|
|
public func run(
|
|
binary: String,
|
|
send script: String,
|
|
options: Options = Options(),
|
|
onURLDetected: (@Sendable () -> Void)? = nil) throws -> Result
|
|
{
|
|
let resolved: String
|
|
if FileManager.default.isExecutableFile(atPath: binary) {
|
|
resolved = binary
|
|
} else if let hit = Self.which(binary) {
|
|
resolved = hit
|
|
} else {
|
|
Self.log.warning("PTY binary not found", metadata: ["binary": binary])
|
|
throw Error.binaryNotFound(binary)
|
|
}
|
|
|
|
let binaryName = URL(fileURLWithPath: resolved).lastPathComponent
|
|
Self.log.debug(
|
|
"PTY start",
|
|
metadata: [
|
|
"binary": binaryName,
|
|
"timeout": "\(options.timeout)",
|
|
"rows": "\(options.rows)",
|
|
"cols": "\(options.cols)",
|
|
"args": "\(options.extraArgs.count)",
|
|
])
|
|
|
|
var primaryFD: Int32 = -1
|
|
var secondaryFD: Int32 = -1
|
|
var win = winsize(ws_row: options.rows, ws_col: options.cols, ws_xpixel: 0, ws_ypixel: 0)
|
|
guard openpty(&primaryFD, &secondaryFD, nil, nil, &win) == 0 else {
|
|
Self.log.warning("PTY openpty failed", metadata: ["binary": binaryName])
|
|
throw Error.launchFailed("openpty failed")
|
|
}
|
|
// Make primary side non-blocking so read loops don't hang when no data is available.
|
|
_ = fcntl(primaryFD, F_SETFL, O_NONBLOCK)
|
|
|
|
let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true)
|
|
let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true)
|
|
|
|
func checkCancellation() throws {
|
|
if options.cancellationCheck() {
|
|
throw CancellationError()
|
|
}
|
|
}
|
|
|
|
func writeAllToPrimary(_ data: Data) throws {
|
|
try data.withUnsafeBytes { rawBytes in
|
|
guard let baseAddress = rawBytes.baseAddress else { return }
|
|
var offset = 0
|
|
var retries = 0
|
|
while offset < rawBytes.count {
|
|
try checkCancellation()
|
|
let written = write(primaryFD, baseAddress.advanced(by: offset), rawBytes.count - offset)
|
|
if written > 0 {
|
|
offset += written
|
|
retries = 0
|
|
continue
|
|
}
|
|
if written == 0 { break }
|
|
|
|
let err = errno
|
|
if err == EAGAIN || err == EWOULDBLOCK {
|
|
retries += 1
|
|
if retries > 200 {
|
|
throw Error.launchFailed("write to PTY would block")
|
|
}
|
|
usleep(5000)
|
|
continue
|
|
}
|
|
throw Error.launchFailed("write to PTY failed: \(String(cString: strerror(err)))")
|
|
}
|
|
}
|
|
}
|
|
|
|
let baseEnv = options.baseEnvironment ?? ProcessInfo.processInfo.environment
|
|
let isClaudeCLI = Self.isClaudeBinary(requested: binary, resolved: resolved, environment: baseEnv)
|
|
let executable: String
|
|
let arguments: [String]
|
|
if isClaudeCLI,
|
|
let watchdog = Self.locateBundledHelper("CodexBarClaudeWatchdog")
|
|
{
|
|
executable = watchdog
|
|
arguments = ["--", resolved] + options.extraArgs
|
|
} else {
|
|
executable = resolved
|
|
arguments = options.extraArgs
|
|
}
|
|
// Use login-shell PATH when available, but keep the caller’s environment (HOME, LANG, etc.) so
|
|
// the CLIs can find their auth/config files.
|
|
var env = Self.enrichedEnvironment(baseEnv: baseEnv, home: baseEnv["HOME"] ?? NSHomeDirectory())
|
|
let workingDirectory = options.workingDirectory
|
|
?? (options.useClaudeProbeWorkingDirectory && isClaudeCLI
|
|
? ClaudeStatusProbe.preparedProbeWorkingDirectoryURL()
|
|
: nil)
|
|
if let workingDirectory {
|
|
env["PWD"] = workingDirectory.path
|
|
}
|
|
|
|
var cleanedUp = false
|
|
var launchedProcess: SpawnedProcessGroup?
|
|
/// Always tear down the PTY child (and its process group) even if we throw early
|
|
/// while bootstrapping the CLI (e.g. when it prompts for login/telemetry).
|
|
func cleanup() {
|
|
guard !cleanedUp else { return }
|
|
cleanedUp = true
|
|
|
|
if let launchedProcess, launchedProcess.isRunning {
|
|
Self.log.debug("PTY stopping", metadata: ["binary": binaryName])
|
|
let exitData = Data("/exit\n".utf8)
|
|
try? writeAllToPrimary(exitData)
|
|
}
|
|
|
|
try? secondaryHandle.close()
|
|
|
|
guard let launchedProcess else {
|
|
try? primaryHandle.close()
|
|
return
|
|
}
|
|
launchedProcess.terminateSynchronously()
|
|
try? primaryHandle.close()
|
|
TTYCommandRunnerActiveProcessRegistry.unregister(pid: launchedProcess.pid)
|
|
}
|
|
|
|
guard TTYCommandRunnerActiveProcessRegistry.beginLaunch() else {
|
|
cleanup()
|
|
throw Error.launchFailed("App shutdown in progress")
|
|
}
|
|
var launchReservationHeld = true
|
|
defer {
|
|
if launchReservationHeld {
|
|
TTYCommandRunnerActiveProcessRegistry.endLaunch()
|
|
}
|
|
}
|
|
|
|
// Ensure the PTY process is always torn down, even when we throw early (e.g. login prompt).
|
|
defer { cleanup() }
|
|
|
|
let process: SpawnedProcessGroup
|
|
do {
|
|
try checkCancellation()
|
|
process = try SpawnedProcessGroup.launchPTY(
|
|
binary: executable,
|
|
arguments: arguments,
|
|
environment: env,
|
|
workingDirectory: workingDirectory,
|
|
fileDescriptors: (primary: primaryFD, secondary: secondaryFD))
|
|
launchedProcess = process
|
|
try? secondaryHandle.close()
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch {
|
|
Self.log.warning(
|
|
"PTY launch failed",
|
|
metadata: ["binary": binaryName, "error": error.localizedDescription])
|
|
throw Error.launchFailed(error.localizedDescription)
|
|
}
|
|
|
|
let pid = process.pid
|
|
guard TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binaryName) else {
|
|
Self.log.debug("PTY launch blocked by shutdown fence", metadata: ["binary": binaryName])
|
|
throw Error.launchFailed("App shutdown in progress")
|
|
}
|
|
TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: process.processGroup)
|
|
TTYCommandRunnerActiveProcessRegistry.endLaunch()
|
|
launchReservationHeld = false
|
|
Self.log.debug("PTY launched", metadata: ["binary": binaryName])
|
|
|
|
func send(_ text: String) throws {
|
|
guard let data = text.data(using: .utf8) else { return }
|
|
try writeAllToPrimary(data)
|
|
}
|
|
|
|
let deadline = Date().addingTimeInterval(options.timeout)
|
|
let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let isCodex = (binaryName == "codex") || options.forceCodexStatusMode
|
|
let isCodexStatus = isCodex && trimmed == "/status"
|
|
|
|
var buffer = Data()
|
|
func readChunkResult() -> (data: Data, terminalRead: Int, errno: Int32) {
|
|
var appended = Data()
|
|
var terminalRead = 0
|
|
var terminalErrno: Int32 = 0
|
|
while true {
|
|
var tmp = [UInt8](repeating: 0, count: 8192)
|
|
errno = 0
|
|
let n = read(primaryFD, &tmp, tmp.count)
|
|
if n > 0 {
|
|
let slice = tmp.prefix(n)
|
|
buffer.append(contentsOf: slice)
|
|
appended.append(contentsOf: slice)
|
|
continue
|
|
}
|
|
terminalRead = Int(n)
|
|
terminalErrno = errno
|
|
break
|
|
}
|
|
return (appended, terminalRead, terminalErrno)
|
|
}
|
|
|
|
func readChunk() -> Data {
|
|
readChunkResult().data
|
|
}
|
|
|
|
func readDrainChunk() -> DrainReadResult {
|
|
let result = readChunkResult()
|
|
return Self.drainReadResult(for: result.data, terminalRead: result.terminalRead, errno: result.errno)
|
|
}
|
|
|
|
func firstLink(in data: Data) -> String? {
|
|
guard let s = String(data: data, encoding: .utf8) else { return nil }
|
|
let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
|
guard let match = regex.firstMatch(in: s, range: range), let r = Range(match.range, in: s) else {
|
|
return nil
|
|
}
|
|
var url = String(s[r])
|
|
while let last = url.unicodeScalars.last,
|
|
CharacterSet(charactersIn: ".,;:)]}>\"'").contains(last)
|
|
{
|
|
url.unicodeScalars.removeLast()
|
|
}
|
|
return url
|
|
}
|
|
|
|
let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E])
|
|
|
|
usleep(UInt32(options.initialDelay * 1_000_000))
|
|
try checkCancellation()
|
|
|
|
// Generic path for non-Codex (e.g. Claude /login)
|
|
if !isCodex {
|
|
if !trimmed.isEmpty {
|
|
try send(trimmed)
|
|
try send("\r")
|
|
}
|
|
|
|
let stopNeedles = options.stopOnSubstrings.map { Data($0.utf8) }
|
|
let sendNeedles = options.sendOnSubstrings.map { (
|
|
needle: Data($0.key.utf8),
|
|
needleString: $0.key,
|
|
keys: Data($0.value.utf8)) }
|
|
let urlNeedles = [Data("https://".utf8), Data("http://".utf8)]
|
|
let needleLengths =
|
|
stopNeedles.map(\.count) +
|
|
sendNeedles.map(\.needle.count) +
|
|
urlNeedles.map(\.count) +
|
|
[cursorQuery.count]
|
|
let maxNeedle = needleLengths.max() ?? cursorQuery.count
|
|
var scanBuffer = RollingBuffer(maxNeedle: maxNeedle)
|
|
var nextCursorCheckAt = Date(timeIntervalSince1970: 0)
|
|
var lastEnter = Date()
|
|
var stoppedEarly = false
|
|
var urlSeen = false
|
|
var triggeredSends = Set<Data>()
|
|
var recentText = ""
|
|
var lastOutputAt = Date()
|
|
var terminatedForIdle = false
|
|
|
|
func processNonCodexChunk(_ newData: Data, allowSends: Bool, allowStop: Bool) -> Bool {
|
|
guard !newData.isEmpty else { return false }
|
|
|
|
lastOutputAt = Date()
|
|
if let chunkText = String(bytes: newData, encoding: .utf8) {
|
|
recentText += chunkText
|
|
if recentText.count > 8192 {
|
|
recentText.removeFirst(recentText.count - 8192)
|
|
}
|
|
}
|
|
|
|
let scanData = scanBuffer.append(newData)
|
|
if Date() >= nextCursorCheckAt,
|
|
scanData.range(of: cursorQuery) != nil
|
|
{
|
|
try? send("\u{1b}[1;1R")
|
|
nextCursorCheckAt = Date().addingTimeInterval(1.0)
|
|
}
|
|
|
|
if allowSends, !sendNeedles.isEmpty {
|
|
let recentTextCollapsed = recentText.replacingOccurrences(of: "\r", with: "")
|
|
for item in sendNeedles where !triggeredSends.contains(item.needle) {
|
|
let matched = scanData.range(of: item.needle) != nil ||
|
|
recentText.contains(item.needleString) ||
|
|
recentTextCollapsed.contains(item.needleString)
|
|
if matched {
|
|
if let keysString = String(data: item.keys, encoding: .utf8) {
|
|
try? send(keysString)
|
|
} else {
|
|
try? writeAllToPrimary(item.keys)
|
|
}
|
|
triggeredSends.insert(item.needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
if urlNeedles.contains(where: { scanData.range(of: $0) != nil }) {
|
|
if !urlSeen {
|
|
urlSeen = true
|
|
onURLDetected?()
|
|
}
|
|
if allowStop, options.stopOnURL {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if allowStop, !stopNeedles.isEmpty, stopNeedles.contains(where: { scanData.range(of: $0) != nil }) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
while Date() < deadline {
|
|
try checkCancellation()
|
|
let readResult = readDrainChunk()
|
|
let newData = switch readResult {
|
|
case let .data(data):
|
|
data
|
|
case .wouldBlock, .closed:
|
|
Data()
|
|
}
|
|
if processNonCodexChunk(newData, allowSends: true, allowStop: true) {
|
|
stoppedEarly = true
|
|
break
|
|
}
|
|
if let idleTimeout = options.idleTimeout,
|
|
!buffer.isEmpty,
|
|
Date().timeIntervalSince(lastOutputAt) >= idleTimeout
|
|
{
|
|
stoppedEarly = true
|
|
terminatedForIdle = true
|
|
break
|
|
}
|
|
|
|
if !urlSeen, let every = options.sendEnterEvery, Date().timeIntervalSince(lastEnter) >= every {
|
|
try? send("\r")
|
|
lastEnter = Date()
|
|
}
|
|
|
|
if case .closed = readResult, !process.isRunning { break }
|
|
if !process.isRunning { break }
|
|
usleep(60000)
|
|
}
|
|
|
|
if stoppedEarly {
|
|
let settle = max(0, min(options.settleAfterStop, deadline.timeIntervalSinceNow))
|
|
if settle > 0 {
|
|
let settleDeadline = Date().addingTimeInterval(settle)
|
|
while Date() < settleDeadline {
|
|
try checkCancellation()
|
|
let newData = readChunk()
|
|
let scanData = scanBuffer.append(newData)
|
|
if Date() >= nextCursorCheckAt,
|
|
!scanData.isEmpty,
|
|
scanData.range(of: cursorQuery) != nil
|
|
{
|
|
try? send("\u{1b}[1;1R")
|
|
nextCursorCheckAt = Date().addingTimeInterval(1.0)
|
|
}
|
|
usleep(50000)
|
|
}
|
|
}
|
|
} else if !process.isRunning {
|
|
// PTY-backed scripts can exit before their final echo becomes readable on the parent side.
|
|
// Give the kernel a brief non-blocking drain window so we don't lose the last line of output.
|
|
let drainFor = max(0, min(0.2, deadline.timeIntervalSinceNow))
|
|
if drainFor > 0 {
|
|
Self.drainRemainingOutput(
|
|
until: Date().addingTimeInterval(drainFor),
|
|
readChunk: readDrainChunk,
|
|
processChunk: { _ = processNonCodexChunk($0, allowSends: false, allowStop: false) })
|
|
}
|
|
}
|
|
|
|
let text = String(data: buffer, encoding: .utf8) ?? ""
|
|
let completion: Result.Completion = if !process.isRunning {
|
|
.processExited(status: process.finishSynchronously() ?? 1)
|
|
} else if terminatedForIdle {
|
|
.idleTimeout
|
|
} else if stoppedEarly {
|
|
.outputCondition
|
|
} else {
|
|
.deadlineExceeded
|
|
}
|
|
if text.isEmpty {
|
|
guard options.returnOnEmptyProcessExit,
|
|
case .processExited = completion
|
|
else {
|
|
throw Error.timedOut
|
|
}
|
|
}
|
|
return Result(text: text, completion: completion)
|
|
}
|
|
|
|
// Codex-specific behavior (/status and update handling)
|
|
let delayInitialSend = isCodexStatus
|
|
if !delayInitialSend {
|
|
try send(script)
|
|
try send("\r")
|
|
usleep(150_000)
|
|
try send("\r")
|
|
try send("\u{1b}")
|
|
}
|
|
|
|
var skippedCodexUpdate = false
|
|
var sentScript = !delayInitialSend
|
|
var updateSkipAttempts = 0
|
|
var lastEnter = Date(timeIntervalSince1970: 0)
|
|
var scriptSentAt: Date? = sentScript ? Date() : nil
|
|
var resendStatusRetries = 0
|
|
var enterRetries = 0
|
|
var sawCodexStatus = false
|
|
var sawCodexUpdatePrompt = false
|
|
let statusMarkers = [
|
|
"Credits:",
|
|
"5h limit",
|
|
"5-hour limit",
|
|
"Weekly limit",
|
|
].map { Data($0.utf8) }
|
|
let updateNeedles = ["Update available!", "Run bun install -g @openai/codex", "0.60.1 ->"]
|
|
let updateNeedlesLower = updateNeedles.map { Data($0.lowercased().utf8) }
|
|
let statusNeedleLengths = statusMarkers.map(\.count)
|
|
let updateNeedleLengths = updateNeedlesLower.map(\.count)
|
|
let statusMaxNeedle = ([cursorQuery.count] + statusNeedleLengths).max() ?? cursorQuery.count
|
|
let updateMaxNeedle = updateNeedleLengths.max() ?? 0
|
|
var statusScanBuffer = RollingBuffer(maxNeedle: statusMaxNeedle)
|
|
var updateScanBuffer = RollingBuffer(maxNeedle: updateMaxNeedle)
|
|
var nextCursorCheckAt = Date(timeIntervalSince1970: 0)
|
|
|
|
while Date() < deadline {
|
|
try checkCancellation()
|
|
let newData = readChunk()
|
|
let scanData = statusScanBuffer.append(newData)
|
|
if Date() >= nextCursorCheckAt,
|
|
!scanData.isEmpty,
|
|
scanData.range(of: cursorQuery) != nil
|
|
{
|
|
try? send("\u{1b}[1;1R")
|
|
nextCursorCheckAt = Date().addingTimeInterval(1.0)
|
|
}
|
|
if !scanData.isEmpty, !sawCodexStatus {
|
|
if statusMarkers.contains(where: { scanData.range(of: $0) != nil }) {
|
|
sawCodexStatus = true
|
|
}
|
|
}
|
|
|
|
if !skippedCodexUpdate, !sawCodexUpdatePrompt, !newData.isEmpty {
|
|
let lowerData = Self.lowercasedASCII(newData)
|
|
let lowerScan = updateScanBuffer.append(lowerData)
|
|
if !sawCodexUpdatePrompt {
|
|
if updateNeedlesLower.contains(where: { lowerScan.range(of: $0) != nil }) {
|
|
sawCodexUpdatePrompt = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !skippedCodexUpdate, sawCodexUpdatePrompt {
|
|
// Prompt shows options: 1) Update now, 2) Skip, 3) Skip until next version.
|
|
// Users report one Down + Enter is enough; follow with an extra Enter for safety, then re-run
|
|
// /status.
|
|
try? send("\u{1b}[B") // highlight option 2 (Skip)
|
|
usleep(120_000)
|
|
try? send("\r")
|
|
usleep(150_000)
|
|
try? send("\r") // if still focused on prompt, confirm again
|
|
try? send("/status")
|
|
try? send("\r")
|
|
updateSkipAttempts += 1
|
|
if updateSkipAttempts >= 1 {
|
|
skippedCodexUpdate = true
|
|
sentScript = false // re-send /status after dismissing
|
|
scriptSentAt = nil
|
|
buffer.removeAll()
|
|
statusScanBuffer.reset()
|
|
updateScanBuffer.reset()
|
|
sawCodexStatus = false
|
|
}
|
|
usleep(300_000)
|
|
}
|
|
if !sentScript, !sawCodexUpdatePrompt || skippedCodexUpdate {
|
|
try? send(script)
|
|
try? send("\r")
|
|
sentScript = true
|
|
scriptSentAt = Date()
|
|
lastEnter = Date()
|
|
usleep(200_000)
|
|
continue
|
|
}
|
|
if sentScript, !sawCodexStatus {
|
|
if Date().timeIntervalSince(lastEnter) >= 1.2, enterRetries < 6 {
|
|
try? send("\r")
|
|
enterRetries += 1
|
|
lastEnter = Date()
|
|
usleep(120_000)
|
|
continue
|
|
}
|
|
if let sentAt = scriptSentAt,
|
|
Date().timeIntervalSince(sentAt) >= 3.0,
|
|
resendStatusRetries < 2
|
|
{
|
|
try? send("/status")
|
|
try? send("\r")
|
|
resendStatusRetries += 1
|
|
buffer.removeAll()
|
|
statusScanBuffer.reset()
|
|
updateScanBuffer.reset()
|
|
sawCodexStatus = false
|
|
scriptSentAt = Date()
|
|
lastEnter = Date()
|
|
usleep(220_000)
|
|
continue
|
|
}
|
|
}
|
|
if sawCodexStatus { break }
|
|
usleep(120_000)
|
|
}
|
|
|
|
if sawCodexStatus {
|
|
let settleDeadline = Date().addingTimeInterval(2.0)
|
|
while Date() < settleDeadline {
|
|
try checkCancellation()
|
|
let newData = readChunk()
|
|
let scanData = statusScanBuffer.append(newData)
|
|
if Date() >= nextCursorCheckAt,
|
|
!scanData.isEmpty,
|
|
scanData.range(of: cursorQuery) != nil
|
|
{
|
|
try? send("\u{1b}[1;1R")
|
|
nextCursorCheckAt = Date().addingTimeInterval(1.0)
|
|
}
|
|
usleep(100_000)
|
|
}
|
|
}
|
|
|
|
guard let text = String(data: buffer, encoding: .utf8), !text.isEmpty else {
|
|
throw Error.timedOut
|
|
}
|
|
|
|
let completion: Result.Completion = if !process.isRunning {
|
|
.processExited(status: process.finishSynchronously() ?? 1)
|
|
} else if sawCodexStatus {
|
|
.outputCondition
|
|
} else {
|
|
.deadlineExceeded
|
|
}
|
|
return Result(text: text, completion: completion)
|
|
}
|
|
|
|
// swiftlint:enable function_body_length
|
|
}
|
|
|
|
extension TTYCommandRunner {
|
|
public static func which(_ tool: String) -> String? {
|
|
if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located }
|
|
if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { return located }
|
|
return self.runWhich(tool)
|
|
}
|
|
|
|
private static func isClaudeBinary(requested: String, resolved: String, environment: [String: String]) -> Bool {
|
|
let requestedName = URL(fileURLWithPath: requested).lastPathComponent
|
|
let resolvedName = URL(fileURLWithPath: resolved).lastPathComponent
|
|
if requested == "claude" || requestedName == "claude" || resolvedName == "claude" {
|
|
return true
|
|
}
|
|
|
|
guard let override = environment["CLAUDE_CLI_PATH"], !override.isEmpty else { return false }
|
|
let normalizedOverride = self.normalizedExecutablePath(override)
|
|
return self.normalizedExecutablePath(resolved) == normalizedOverride
|
|
|| self.normalizedExecutablePath(requested) == normalizedOverride
|
|
}
|
|
|
|
private static func normalizedExecutablePath(_ path: String) -> String {
|
|
let expanded = NSString(string: path).expandingTildeInPath
|
|
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
|
if realpath(expanded, &buffer) != nil {
|
|
return buffer.withUnsafeBufferPointer { rawBuffer in
|
|
guard let baseAddress = rawBuffer.baseAddress else { return expanded }
|
|
return String(cString: baseAddress)
|
|
}
|
|
}
|
|
return URL(fileURLWithPath: expanded).standardizedFileURL.path
|
|
}
|
|
|
|
private static func runWhich(_ tool: String) -> String? {
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
|
proc.arguments = [tool]
|
|
var env = ProcessInfo.processInfo.environment
|
|
let loginPATH = LoginShellPathCache.shared.currentOrCapture()
|
|
env["PATH"] = PathBuilder.effectivePATH(
|
|
purposes: [.tty, .nodeTooling],
|
|
env: env,
|
|
loginPATH: loginPATH)
|
|
proc.environment = env
|
|
let pipe = Pipe()
|
|
proc.standardOutput = pipe
|
|
try? proc.run()
|
|
proc.waitUntilExit()
|
|
guard proc.terminationStatus == 0 else { return nil }
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
guard let path = String(data: data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!path.isEmpty else { return nil }
|
|
return path
|
|
}
|
|
|
|
/// Uses login-shell PATH when available so TTY probes match the user's shell configuration.
|
|
public static func enrichedPath() -> String {
|
|
PathBuilder.effectivePATH(
|
|
purposes: [.tty, .nodeTooling],
|
|
env: ProcessInfo.processInfo.environment)
|
|
}
|
|
|
|
static func enrichedEnvironment(
|
|
baseEnv: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
home: String = NSHomeDirectory()) -> [String: String]
|
|
{
|
|
var env = baseEnv
|
|
env["PATH"] = PathBuilder.effectivePATH(
|
|
purposes: [.tty, .nodeTooling],
|
|
env: baseEnv,
|
|
loginPATH: loginPATH,
|
|
home: home)
|
|
if env["HOME"]?.isEmpty ?? true {
|
|
env["HOME"] = home
|
|
}
|
|
if env["TERM"]?.isEmpty ?? true {
|
|
env["TERM"] = "xterm-256color"
|
|
}
|
|
if env["COLORTERM"]?.isEmpty ?? true {
|
|
env["COLORTERM"] = "truecolor"
|
|
}
|
|
if env["LANG"]?.isEmpty ?? true {
|
|
env["LANG"] = "en_US.UTF-8"
|
|
}
|
|
if env["CI"] == nil {
|
|
env["CI"] = "0"
|
|
}
|
|
return env
|
|
}
|
|
}
|
|
|
|
extension TTYCommandRunner {
|
|
@discardableResult
|
|
static func registerActiveProcessForAppShutdown(pid: pid_t, binary: String) -> Bool {
|
|
TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binary)
|
|
}
|
|
|
|
static func beginActiveProcessLaunchForAppShutdown() -> Bool {
|
|
TTYCommandRunnerActiveProcessRegistry.beginLaunch()
|
|
}
|
|
|
|
static func endActiveProcessLaunchForAppShutdown() {
|
|
TTYCommandRunnerActiveProcessRegistry.endLaunch()
|
|
}
|
|
|
|
static func updateActiveProcessGroupForAppShutdown(pid: pid_t, processGroup: pid_t?) {
|
|
TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup)
|
|
}
|
|
|
|
static func unregisterActiveProcessForAppShutdown(pid: pid_t) {
|
|
TTYCommandRunnerActiveProcessRegistry.unregister(pid: pid)
|
|
}
|
|
|
|
static func _test_resetTrackedProcesses() {
|
|
TTYCommandRunnerActiveProcessRegistry.reset()
|
|
}
|
|
|
|
static func _test_trackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {
|
|
TTYCommandRunnerActiveProcessRegistry.testTrackProcess(
|
|
pid: pid,
|
|
binary: binary,
|
|
processGroup: processGroup)
|
|
}
|
|
|
|
@discardableResult
|
|
static func _test_registerTrackedProcess(pid: pid_t, binary: String) -> Bool {
|
|
TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binary)
|
|
}
|
|
|
|
static func _test_trackedProcessCount() -> Int {
|
|
TTYCommandRunnerActiveProcessRegistry.count()
|
|
}
|
|
|
|
static func _test_beginTrackedProcessLaunch() -> Bool {
|
|
TTYCommandRunnerActiveProcessRegistry.beginLaunch()
|
|
}
|
|
|
|
static func _test_endTrackedProcessLaunch() {
|
|
TTYCommandRunnerActiveProcessRegistry.endLaunch()
|
|
}
|
|
|
|
static func _test_drainTrackedProcessesForShutdown(
|
|
onFenceSet: (() -> Void)? = nil)
|
|
-> [(pid: pid_t, binary: String, processGroup: pid_t?)]
|
|
{
|
|
TTYCommandRunnerActiveProcessRegistry.drainForShutdown(onFenceSet: onFenceSet)
|
|
}
|
|
|
|
static func _test_resolveShutdownTargets(
|
|
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
|
|
hostProcessGroup: pid_t,
|
|
groupResolver: (pid_t) -> pid_t) -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
|
|
{
|
|
self.resolveShutdownTargets(targets, hostProcessGroup: hostProcessGroup, groupResolver: groupResolver)
|
|
}
|
|
}
|