项目文件夹

文件
2026-07-13 12:22:33 +08:00

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