diff --git a/Orbit.xcodeproj/project.pbxproj b/Orbit.xcodeproj/project.pbxproj index c4a76d9..22aa5f8 100644 --- a/Orbit.xcodeproj/project.pbxproj +++ b/Orbit.xcodeproj/project.pbxproj @@ -416,7 +416,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex"; PRODUCT_NAME = Orbit; REGISTER_APP_GROUPS = YES; @@ -454,7 +454,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex"; PRODUCT_NAME = Orbit; REGISTER_APP_GROUPS = YES; @@ -476,7 +476,7 @@ DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.2; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -497,7 +497,7 @@ DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.2; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -516,7 +516,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -535,7 +535,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 6D7X9GGZAW; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.0.5; PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/Orbit/AppBundleConfiguration.swift b/Orbit/AppBundleConfiguration.swift index 164af9f..e468239 100644 --- a/Orbit/AppBundleConfiguration.swift +++ b/Orbit/AppBundleConfiguration.swift @@ -8,6 +8,15 @@ import Foundation enum AppBundleConfiguration { + static var showsCodexDebugInfo: Bool { +#if DEBUG + let defaultValue = true +#else + let defaultValue = false +#endif + return boolValue(forKey: "OrbitShowCodexDebug", defaultValue: defaultValue) + } + static func stringValue(forKey key: String) -> String? { if let environmentValue = ProcessInfo.processInfo.environment[environmentVariableName(for: key)] { let trimmedValue = environmentValue.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Orbit/CodexAppServerActionProvider.swift b/Orbit/CodexAppServerActionProvider.swift index ae81fc1..18669d1 100644 --- a/Orbit/CodexAppServerActionProvider.swift +++ b/Orbit/CodexAppServerActionProvider.swift @@ -1,9 +1,29 @@ import AppKit import Foundation +private enum OrbitMcpStartupState: Equatable { + case unknown + case starting + case ready + case failed(String?) + + var isResolved: Bool { + switch self { + case .ready, .failed: + return true + case .unknown, .starting: + return false + } + } +} + @MainActor final class CodexAppServerActionProvider: ActionProvider { let displayName = "Codex" + private static let browserToolServerNames = ["playwright", "chrome-devtools"] + private static func makeInitialMcpStartupStates() -> [String: OrbitMcpStartupState] { + Dictionary(uniqueKeysWithValues: browserToolServerNames.map { ($0, .unknown) }) + } private(set) var status: OrbitActionStatus = .idle private let settings = OrbitSettings.shared private(set) var authState: OrbitCodexAuthState = .unknown @@ -46,8 +66,10 @@ final class CodexAppServerActionProvider: ActionProvider { private var lastLoginURL: URL? private var lastLoginID: String? private var loginRequestTimeoutTask: Task? + private var pendingTurnStartRetryTask: Task? private var lastEmittedLiveCommentary: String? private var preparedCodexHome: OrbitPreparedCodexHome? + private var mcpStartupStates: [String: OrbitMcpStartupState] = CodexAppServerActionProvider.makeInitialMcpStartupStates() var stateDidChange: (() -> Void)? var isConfigured: Bool { @@ -233,7 +255,7 @@ final class CodexAppServerActionProvider: ActionProvider { streamedCommentaryBuffer = "" hasEmittedEarlyCommentary = false lastEmittedLiveCommentary = nil - sendTurnStart() + attemptPendingTurnStart() } func respondToToolPrompt(requestID: Int, questionID: String, answer: String) { @@ -453,6 +475,7 @@ final class CodexAppServerActionProvider: ActionProvider { serviceTier: resolvedServiceTier ) self.preparedCodexHome = preparedCodexHome + mcpStartupStates = Self.makeInitialMcpStartupStates() let launchCommand = resolvedCodexLaunchCommand( for: codexExecutable, preparedCodexHome: preparedCodexHome @@ -801,7 +824,7 @@ final class CodexAppServerActionProvider: ActionProvider { appendDebugEvent("<- thread/start ok \(String(threadID.suffix(6)))") notifyStateChanged() if pendingPrompt != nil { - sendTurnStart() + attemptPendingTurnStart() } else { status = .idle } @@ -934,11 +957,72 @@ final class CodexAppServerActionProvider: ActionProvider { stateDidChange?() } + private func attemptPendingTurnStart(forceAfterTimeout: Bool = false) { + guard activeThreadID != nil, pendingPrompt != nil, !isAwaitingTurnCompletion else { return } + + if browserToolStartupStillPending && !forceAfterTimeout { + emitPhase( + .startingCodex, + detail: "connecting browser tools.", + rawSource: "waiting for browser tools" + ) + schedulePendingTurnStartRetryIfNeeded() + return + } + + pendingTurnStartRetryTask?.cancel() + pendingTurnStartRetryTask = nil + sendTurnStart() + } + + private var browserToolStartupStillPending: Bool { + Self.browserToolServerNames.contains { serverName in + guard let state = mcpStartupStates[serverName] else { return false } + return !state.isResolved + } + } + + private func schedulePendingTurnStartRetryIfNeeded() { + guard pendingTurnStartRetryTask == nil else { return } + pendingTurnStartRetryTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self else { return } + self.pendingTurnStartRetryTask = nil + self.attemptPendingTurnStart(forceAfterTimeout: true) + } + } + + private var runtimeCapabilityNote: String? { + let browserFailures = Self.browserToolServerNames.compactMap { serverName -> String? in + guard case .failed(let errorMessage) = mcpStartupStates[serverName] else { return nil } + let detail = errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) + if let detail, !detail.isEmpty { + return "- \(serverName) browser tools are unavailable in this Orbit session: \(detail)" + } + return "- \(serverName) browser tools are unavailable in this Orbit session" + } + + guard !browserFailures.isEmpty else { return nil } + + return """ + Runtime capability note: + \(browserFailures.joined(separator: "\n")) + - do not claim browser control is available unless the tools actually work in this session + """ + } + private func sendTurnStart() { guard let threadID = activeThreadID, let pendingPrompt else { return } var inputItems: [[String: Any]] = [] + if let runtimeCapabilityNote { + inputItems.append([ + "type": "text", + "text": runtimeCapabilityNote + ]) + } + if let latestRequest, let screenshotPath = latestRequest.screenshotPath, !screenshotPath.isEmpty { @@ -1004,6 +1088,13 @@ final class CodexAppServerActionProvider: ActionProvider { var inputItems: [[String: Any]] = [] + if let runtimeCapabilityNote { + inputItems.append([ + "type": "text", + "text": runtimeCapabilityNote + ]) + } + if let latestRequest, let screenshotPath = latestRequest.screenshotPath, !screenshotPath.isEmpty { @@ -1255,10 +1346,38 @@ final class CodexAppServerActionProvider: ActionProvider { return } + if Self.browserToolServerNames.contains(serverName) { + let errorMessage = (params["error"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + switch status { + case "starting": + mcpStartupStates[serverName] = .starting + case "ready": + mcpStartupStates[serverName] = .ready + case "failed": + mcpStartupStates[serverName] = .failed(errorMessage) + default: + break + } + } + if status == "starting" { emitPhase(.startingCodex, detail: "starting \(serverName) tools.", rawSource: "starting \(serverName) tools") } else if status == "ready" && (serverName == "playwright" || serverName == "chrome-devtools") { emitPhase(.thinking, rawSource: "\(serverName) tools are ready") + } else if status == "failed" && (serverName == "playwright" || serverName == "chrome-devtools") { + let detail = { + let trimmed = (params["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + return trimmed + } + return "browser tools failed to start." + }() + appendDebugEvent("\(serverName) tools failed: \(detail)") + } + + if pendingPrompt != nil, activeThreadID != nil, !isAwaitingTurnCompletion { + attemptPendingTurnStart() } } @@ -1992,6 +2111,8 @@ final class CodexAppServerActionProvider: ActionProvider { loginRequestTimeoutTask = nil startupTimeoutTask?.cancel() startupTimeoutTask = nil + pendingTurnStartRetryTask?.cancel() + pendingTurnStartRetryTask = nil stdoutHandle?.readabilityHandler = nil stderrHandle?.readabilityHandler = nil try? stdinHandle?.close() @@ -2024,6 +2145,7 @@ final class CodexAppServerActionProvider: ActionProvider { pendingLogoutRequestID = nil lastLoginURL = nil lastLoginID = nil + mcpStartupStates = Self.makeInitialMcpStartupStates() authState = .unknown lastEmittedProgress = nil notifyStateChanged() diff --git a/Orbit/CodexRuntimeNode.entitlements b/Orbit/CodexRuntimeNode.entitlements new file mode 100644 index 0000000..d35e43a --- /dev/null +++ b/Orbit/CodexRuntimeNode.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/Orbit/OrbitPanelView.swift b/Orbit/OrbitPanelView.swift index ddbfef7..d4f3d12 100644 --- a/Orbit/OrbitPanelView.swift +++ b/Orbit/OrbitPanelView.swift @@ -999,35 +999,37 @@ struct OrbitPanelView: View { aboutLink(icon: "cup.and.saucer", title: "Buy Me a Coffee", url: "https://buymeacoffee.com/4xiom") } - Divider() - - VStack(alignment: .leading, spacing: 8) { - Text("Codex Debug") - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundColor(.secondary) + if AppBundleConfiguration.showsCodexDebugInfo { + Divider() - if let activeTurnSummary = orbitManager.codexActiveTurnSummary, !activeTurnSummary.isEmpty { - Text(activeTurnSummary) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + VStack(alignment: .leading, spacing: 8) { + Text("Codex Debug") + .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundColor(.secondary) - } - if !orbitManager.codexCollaborationModes.isEmpty { - Text("modes: \(orbitManager.codexCollaborationModes.joined(separator: ", "))") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } + if let activeTurnSummary = orbitManager.codexActiveTurnSummary, !activeTurnSummary.isEmpty { + Text(activeTurnSummary) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + } - if !orbitManager.codexDebugEvents.isEmpty { - Text(orbitManager.codexDebugEvents.suffix(4).joined(separator: "\n")) - .font(.system(size: 9, weight: .medium, design: .monospaced)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + if !orbitManager.codexCollaborationModes.isEmpty { + Text("modes: \(orbitManager.codexCollaborationModes.joined(separator: ", "))") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !orbitManager.codexDebugEvents.isEmpty { + Text(orbitManager.codexDebugEvents.suffix(4).joined(separator: "\n")) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } - } - Divider() + Divider() + } Text("Open source under MIT license") .font(.system(size: 10, weight: .medium)) diff --git a/scripts/release.sh b/scripts/release.sh index 2db802e..7864442 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -132,6 +132,7 @@ xcodebuild -exportArchive \ -exportOptionsPlist "${EXPORT_OPTIONS}" EXPORT_APP_PATH="${EXPORT_DIR}/${APP_NAME}.app" +NODE_ENTITLEMENTS_PATH="${PROJECT_DIR}/Orbit/CodexRuntimeNode.entitlements" if [[ -f "${EXPORT_APP_PATH}/Contents/Resources/LocalSecrets.plist" ]]; then echo "๐Ÿงผ Removing bundled LocalSecrets from release app..." rm -f "${EXPORT_APP_PATH}/Contents/Resources/LocalSecrets.plist" @@ -139,7 +140,16 @@ fi echo "๐Ÿ” Re-signing bundled runtime executables..." while IFS= read -r executable_path; do - codesign --force --sign "${DEVELOPER_ID_IDENTITY}" --options runtime --timestamp "${executable_path}" + if [[ "$(basename "${executable_path}")" == "node" ]]; then + codesign --force \ + --sign "${DEVELOPER_ID_IDENTITY}" \ + --options runtime \ + --timestamp \ + --entitlements "${NODE_ENTITLEMENTS_PATH}" \ + "${executable_path}" + else + codesign --force --sign "${DEVELOPER_ID_IDENTITY}" --options runtime --timestamp "${executable_path}" + fi done < <(find "${EXPORT_APP_PATH}/Contents/Resources/CodexRuntime" -type f -perm -111 | sort) codesign --force \