Files
steipete--codexbar/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
T
2026-07-13 12:22:33 +08:00

1198 lines
45 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 callers 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)
}
}