Files
2026-07-13 12:22:33 +08:00

500 lines
18 KiB
Swift

#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif
import Foundation
actor ClaudeCLISession {
static let shared = ClaudeCLISession()
private static let log = CodexBarLog.logger(LogCategories.claudeCLI)
#if DEBUG
@TaskLocal private static var sessionOverrideForTesting: ClaudeCLISession?
static var current: ClaudeCLISession {
self.sessionOverrideForTesting ?? self.shared
}
static func withIsolatedSessionForTesting<T>(operation: () async throws -> T) async rethrows -> T {
let session = ClaudeCLISession()
defer { Task { await session.reset() } }
return try await self.$sessionOverrideForTesting.withValue(session) {
try await operation()
}
}
#else
static var current: ClaudeCLISession {
self.shared
}
#endif
enum SessionError: LocalizedError {
case launchFailed(String)
case ioFailed(String)
case timedOut
case processExited
var errorDescription: String? {
switch self {
case let .launchFailed(msg): "Failed to launch Claude CLI session: \(msg)"
case let .ioFailed(msg): "Claude CLI PTY I/O failed: \(msg)"
case .timedOut: "Claude CLI session timed out."
case .processExited: "Claude CLI session exited."
}
}
}
private var process: Process?
private var primaryFD: Int32 = -1
private var primaryHandle: FileHandle?
private var secondaryHandle: FileHandle?
private var processGroup: pid_t?
private var binaryPath: String?
private var startedAt: Date?
private let promptSends: [String: String] = [
"Do you trust the files in this folder?": "y\r",
"Quick safety check:": "\r",
"Yes, I trust this folder": "\r",
"Ready to code here?": "\r",
"Press Enter to continue": "\r",
]
private 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
}
}
private static func normalizedNeedle(_ text: String) -> String {
String(text.lowercased().filter { !$0.isWhitespace })
}
private static func commandPaletteSends(for subcommand: String) -> [String: String] {
let normalized = subcommand.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "/usage":
// Claude's command palette can render several "Show ..." actions together; only auto-confirm the
// usage-related actions here so we do not accidentally execute /status.
return [
"Show plan": "\r",
"Show plan usage limits": "\r",
]
case "/status":
return [
"Show Claude Code": "\r",
"Show Claude Code status": "\r",
]
default:
return [:]
}
}
func capture(
subcommand: String,
binary: String,
timeout: TimeInterval,
idleTimeout: TimeInterval? = 3.0,
stopOnSubstrings: [String] = [],
stopWhenNormalized: (@Sendable (String) -> Bool)? = nil,
settleAfterStop: TimeInterval = 0.25,
sendEnterEvery: TimeInterval? = nil) async throws -> String
{
try self.ensureStarted(binary: binary)
if let startedAt {
let sinceStart = Date().timeIntervalSince(startedAt)
// Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the
// original 0.4s to ensure slash commands reliably open their panels.
if sinceStart < 2.0 {
let delay = UInt64((2.0 - sinceStart) * 1_000_000_000)
try await Task.sleep(nanoseconds: delay)
}
}
self.drainOutput()
let trimmed = subcommand.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
try self.send(trimmed)
try self.send("\r")
}
let stopNeedles = stopOnSubstrings.map { Self.normalizedNeedle($0) }
var sendMap = self.promptSends
for (needle, keys) in Self.commandPaletteSends(for: trimmed) {
sendMap[needle] = keys
}
let sendNeedles = sendMap.map { (needle: Self.normalizedNeedle($0.key), keys: $0.value) }
let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E])
let needleLengths =
stopOnSubstrings.map(\.utf8.count) +
sendMap.keys.map(\.utf8.count) +
[cursorQuery.count]
let maxNeedle = needleLengths.max() ?? cursorQuery.count
var scanBuffer = RollingBuffer(maxNeedle: maxNeedle)
var triggeredSends = Set<String>()
var buffer = Data()
var scanTailText = ""
var normalizedScan = ""
var utf8Carry = Data()
let deadline = Date().addingTimeInterval(timeout)
var lastOutputAt = Date()
var lastEnterAt = Date()
var stoppedEarly = false
// Only send periodic Enter when the caller explicitly asks for it (used for /usage rendering).
// For /status, periodic input can keep producing output and prevent idle-timeout short-circuiting.
let effectiveEnterEvery: TimeInterval? = sendEnterEvery
while Date() < deadline {
let newData = self.readChunk()
if !newData.isEmpty {
buffer.append(newData)
lastOutputAt = Date()
Self.appendScanText(newData: newData, scanTailText: &scanTailText, utf8Carry: &utf8Carry)
if scanTailText.count > 8192 {
scanTailText = String(scanTailText.suffix(8192))
}
normalizedScan = Self.normalizedNeedle(TextParsing.stripANSICodes(scanTailText))
let scanData = scanBuffer.append(newData)
if scanData.range(of: cursorQuery) != nil {
try? self.send("\u{1b}[1;1R")
}
for item in sendNeedles where !triggeredSends.contains(item.needle) {
if normalizedScan.contains(item.needle) {
try? self.send(item.keys)
triggeredSends.insert(item.needle)
}
}
if stopNeedles
.contains(where: normalizedScan.contains) || (stopWhenNormalized?(normalizedScan) == true)
{
stoppedEarly = true
break
}
}
if self.shouldStopForIdleTimeout(
idleTimeout: idleTimeout,
bufferIsEmpty: buffer.isEmpty,
lastOutputAt: lastOutputAt)
{
stoppedEarly = true
break
}
self.sendPeriodicEnterIfNeeded(every: effectiveEnterEvery, lastEnterAt: &lastEnterAt)
if let proc = self.process, !proc.isRunning {
throw SessionError.processExited
}
try await Task.sleep(nanoseconds: 60_000_000)
}
if stoppedEarly {
let settle = max(0, min(settleAfterStop, deadline.timeIntervalSinceNow))
if settle > 0 {
let settleDeadline = Date().addingTimeInterval(settle)
while Date() < settleDeadline {
let newData = self.readChunk()
if !newData.isEmpty {
buffer.append(newData)
}
try await Task.sleep(nanoseconds: 50_000_000)
}
}
}
guard !buffer.isEmpty, let text = String(data: buffer, encoding: .utf8) else {
throw SessionError.timedOut
}
return text
}
private static func appendScanText(newData: Data, scanTailText: inout String, utf8Carry: inout Data) {
// PTY reads can split multibyte UTF-8 sequences. Keep a small carry buffer so prompt/stop scanning doesn't
// drop chunks when the decode fails due to an incomplete trailing sequence.
var combined = Data()
combined.reserveCapacity(utf8Carry.count + newData.count)
combined.append(utf8Carry)
combined.append(newData)
if let chunk = String(data: combined, encoding: .utf8) {
scanTailText.append(chunk)
utf8Carry.removeAll(keepingCapacity: true)
return
}
for trimCount in 1...3 where combined.count > trimCount {
let prefix = combined.dropLast(trimCount)
if let chunk = String(data: prefix, encoding: .utf8) {
scanTailText.append(chunk)
utf8Carry = Data(combined.suffix(trimCount))
return
}
}
// If the data is still not UTF-8 decodable, keep only a small suffix to avoid unbounded growth.
utf8Carry = Data(combined.suffix(12))
}
func reset() {
self.cleanup()
}
private func ensureStarted(binary: String) throws {
if let proc = self.process, proc.isRunning, self.binaryPath == binary {
Self.log.debug("Claude CLI session reused")
return
}
self.cleanup()
var primaryFD: Int32 = -1
var secondaryFD: Int32 = -1
var win = winsize(ws_row: 50, ws_col: 160, ws_xpixel: 0, ws_ypixel: 0)
guard openpty(&primaryFD, &secondaryFD, nil, nil, &win) == 0 else {
Self.log.warning("Claude CLI PTY openpty failed")
throw SessionError.launchFailed("openpty failed")
}
_ = fcntl(primaryFD, F_SETFL, O_NONBLOCK)
let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true)
let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true)
let proc = Process()
let resolvedURL = URL(fileURLWithPath: binary)
let disableWatchdog = ProcessInfo.processInfo.environment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] == "1"
if !disableWatchdog,
resolvedURL.lastPathComponent == "claude",
let watchdog = TTYCommandRunner.locateBundledHelper("CodexBarClaudeWatchdog")
{
proc.executableURL = URL(fileURLWithPath: watchdog)
proc.arguments = ["--", binary, "--allowed-tools", ""]
} else {
proc.executableURL = resolvedURL
proc.arguments = ["--allowed-tools", ""]
}
proc.standardInput = secondaryHandle
proc.standardOutput = secondaryHandle
proc.standardError = secondaryHandle
let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL()
proc.currentDirectoryURL = workingDirectory
var env = Self.launchEnvironment()
env["PWD"] = workingDirectory.path
proc.environment = env
guard TTYCommandRunner.beginActiveProcessLaunchForAppShutdown() else {
try? primaryHandle.close()
try? secondaryHandle.close()
throw SessionError.launchFailed("App shutdown in progress")
}
defer { TTYCommandRunner.endActiveProcessLaunchForAppShutdown() }
do {
try proc.run()
Self.log.debug(
"Claude CLI session started",
metadata: ["binary": URL(fileURLWithPath: binary).lastPathComponent])
} catch {
Self.log.warning("Claude CLI launch failed", metadata: ["error": error.localizedDescription])
try? primaryHandle.close()
try? secondaryHandle.close()
throw SessionError.launchFailed(error.localizedDescription)
}
let pid = proc.processIdentifier
guard TTYCommandRunner.registerActiveProcessForAppShutdown(
pid: pid,
binary: URL(fileURLWithPath: binary).lastPathComponent)
else {
proc.terminate()
kill(pid, SIGKILL)
try? primaryHandle.close()
try? secondaryHandle.close()
throw SessionError.launchFailed("App shutdown in progress")
}
var processGroup: pid_t?
if setpgid(pid, pid) == 0 {
processGroup = pid
TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup)
}
self.process = proc
self.primaryFD = primaryFD
self.primaryHandle = primaryHandle
self.secondaryHandle = secondaryHandle
self.processGroup = processGroup
self.binaryPath = binary
self.startedAt = Date()
}
static func launchEnvironment(baseEnv: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] {
var env = self.scrubbedClaudeEnvironment(from: TTYCommandRunner.enrichedEnvironment(baseEnv: baseEnv))
// Passive status and auth probes must not mutate or update the user's Claude CLI installation.
env["DISABLE_AUTOUPDATER"] = "1"
return env
}
private static func scrubbedClaudeEnvironment(from base: [String: String]) -> [String: String] {
var env = base
let explicitKeys: [String] = [
ClaudeOAuthCredentialsStore.environmentTokenKey,
ClaudeOAuthCredentialsStore.environmentScopesKey,
]
for key in explicitKeys {
env.removeValue(forKey: key)
}
for key in env.keys where key.hasPrefix("ANTHROPIC_") {
env.removeValue(forKey: key)
}
return env
}
private func cleanup() {
if self.process != nil {
Self.log.debug("Claude CLI session stopping")
}
if let proc = self.process, proc.isRunning {
try? self.writeAllToPrimary(Data("/exit\r".utf8))
}
try? self.primaryHandle?.close()
try? self.secondaryHandle?.close()
let descendants = self.process.map { TTYProcessTreeTerminator.descendantPIDs(of: $0.processIdentifier) } ?? []
if let proc = self.process, proc.isRunning {
proc.terminate()
}
if let proc = self.process {
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGTERM,
knownDescendants: descendants)
}
let waitDeadline = Date().addingTimeInterval(1.0)
if let proc = self.process {
while proc.isRunning, Date() < waitDeadline {
usleep(100_000)
}
if proc.isRunning {
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGKILL,
knownDescendants: descendants)
} else {
for pid in descendants where pid > 0 {
kill(pid, SIGKILL)
}
}
TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier)
}
self.process = nil
self.primaryHandle = nil
self.secondaryHandle = nil
self.primaryFD = -1
self.processGroup = nil
self.startedAt = nil
}
private func readChunk() -> Data {
guard self.primaryFD >= 0 else { return Data() }
var appended = Data()
while true {
var tmp = [UInt8](repeating: 0, count: 8192)
let n = read(self.primaryFD, &tmp, tmp.count)
if n > 0 {
appended.append(contentsOf: tmp.prefix(n))
continue
}
break
}
return appended
}
private func drainOutput() {
_ = self.readChunk()
}
private func shouldStopForIdleTimeout(
idleTimeout: TimeInterval?,
bufferIsEmpty: Bool,
lastOutputAt: Date) -> Bool
{
guard let idleTimeout, !bufferIsEmpty else { return false }
return Date().timeIntervalSince(lastOutputAt) >= idleTimeout
}
private func sendPeriodicEnterIfNeeded(every: TimeInterval?, lastEnterAt: inout Date) {
guard let every, Date().timeIntervalSince(lastEnterAt) >= every else { return }
try? self.send("\r")
lastEnterAt = Date()
}
private func send(_ text: String) throws {
guard let data = text.data(using: .utf8) else { return }
guard self.primaryFD >= 0 else { throw SessionError.processExited }
try self.writeAllToPrimary(data)
}
private func writeAllToPrimary(_ data: Data) throws {
guard self.primaryFD >= 0 else { throw SessionError.processExited }
try data.withUnsafeBytes { rawBytes in
guard let baseAddress = rawBytes.baseAddress else { return }
var offset = 0
var retries = 0
while offset < rawBytes.count {
let written = write(self.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 == EINTR || err == EAGAIN || err == EWOULDBLOCK {
retries += 1
if retries > 200 {
throw SessionError.ioFailed("write to PTY would block")
}
usleep(5000)
continue
}
throw SessionError.ioFailed("write to PTY failed: \(String(cString: strerror(err)))")
}
}
}
}