#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 = [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.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 = [] 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.stride)) guard size == Int32(MemoryLayout.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 = [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.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..= 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..\"'").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() 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) } }