From 508d7a6117f4f448b389feefa2081aa941c6f0f6 Mon Sep 17 00:00:00 2001 From: jackiexiao <707610215@qq.com> Date: Thu, 16 Jul 2026 21:14:00 +0800 Subject: [PATCH] Fix Codex auto retry submission --- CodexHelper.xcodeproj/project.pbxproj | 4 + Resources/Info.plist | 4 +- Sources/Agent.swift | 198 +++++++++++++++++++++----- Sources/AppDelegate.swift | 89 +++++++++--- Sources/AutoRetryActivity.swift | 10 ++ docs/releasing.md | 10 +- scripts/package-release.sh | 2 +- 7 files changed, 257 insertions(+), 60 deletions(-) create mode 100644 Sources/AutoRetryActivity.swift diff --git a/CodexHelper.xcodeproj/project.pbxproj b/CodexHelper.xcodeproj/project.pbxproj index 1649820..2e106af 100644 --- a/CodexHelper.xcodeproj/project.pbxproj +++ b/CodexHelper.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ A89417938E467E0454851F66 /* MenuBarQuotaCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A460CF51591D5AFDC68AB3E1 /* MenuBarQuotaCard.swift */; }; AAEC6F2242B8FF20C398D0EE /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9E8C27B10C323132510F90 /* Agent.swift */; }; BD2E9EF659189296DBAC8AEA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0AFB7C4710F04FC24262B9C /* AppDelegate.swift */; }; + CDFCD09F0053C6EC8283BAF7 /* AutoRetryActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65AADBB104529ABD2194653 /* AutoRetryActivity.swift */; }; CE2A25B1E25078A8CCAD23EE /* MenuBarStatusRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD91B112C4E9C126946682BE /* MenuBarStatusRow.swift */; }; D5704D21F73B312A65B78743 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD72B414385AF8942EA8B50 /* Dashboard.swift */; }; D61A78269A7469F5A840622E /* MenuBarActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3F1A69EBF614C16C9AB976 /* MenuBarActionBar.swift */; }; @@ -74,6 +75,7 @@ 8401EEF3C4DBE21A5AD03F88 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; A460CF51591D5AFDC68AB3E1 /* MenuBarQuotaCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarQuotaCard.swift; sourceTree = ""; }; AD91B112C4E9C126946682BE /* MenuBarStatusRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusRow.swift; sourceTree = ""; }; + B65AADBB104529ABD2194653 /* AutoRetryActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoRetryActivity.swift; sourceTree = ""; }; B6B2F593D30B90A7CEB8AE43 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; BB6460A841DAE81DCC9BA877 /* QuotaVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaVisuals.swift; sourceTree = ""; }; CCE7E2C0CD198F4A7397604A /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; @@ -127,6 +129,7 @@ children = ( CE9E8C27B10C323132510F90 /* Agent.swift */, F0AFB7C4710F04FC24262B9C /* AppDelegate.swift */, + B65AADBB104529ABD2194653 /* AutoRetryActivity.swift */, F20A8C0EAF1D15280005E685 /* CodexUpdates.swift */, CCE7E2C0CD198F4A7397604A /* Config.swift */, 5CD72B414385AF8942EA8B50 /* Dashboard.swift */, @@ -247,6 +250,7 @@ files = ( AAEC6F2242B8FF20C398D0EE /* Agent.swift in Sources */, BD2E9EF659189296DBAC8AEA /* AppDelegate.swift in Sources */, + CDFCD09F0053C6EC8283BAF7 /* AutoRetryActivity.swift in Sources */, A53FDBEE1F17E6A3CECBE417 /* CodexUpdates.swift in Sources */, 569A8D776E1D496063816162 /* Config.swift in Sources */, D5704D21F73B312A65B78743 /* Dashboard.swift in Sources */, diff --git a/Resources/Info.plist b/Resources/Info.plist index 16f0ea0..f0a63b4 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.8.1 + 0.8.2 CFBundleVersion - 13 + 14 LSMinimumSystemVersion 13.0 LSApplicationCategoryType diff --git a/Sources/Agent.swift b/Sources/Agent.swift index cb97836..ca9032b 100644 --- a/Sources/Agent.swift +++ b/Sources/Agent.swift @@ -27,7 +27,7 @@ private struct SessionIndexEntry: Decodable { } } -func parseRecentThreads(_ data: Data, limit: Int = 12) -> [CodexThreadSummary] { +func parseRecentThreads(_ data: Data, limit: Int = 2_000) -> [CodexThreadSummary] { guard let text = String(data: data, encoding: .utf8) else { return [] } let decoder = JSONDecoder() var seen = Set() @@ -46,6 +46,23 @@ func parseRecentThreads(_ data: Data, limit: Int = 12) -> [CodexThreadSummary] { return results } +private let codexComposerPlaceholders: Set = [ + "Ask for follow-up changes", + "Ask for follow up changes or @ to tag an agent", + "要求后续变更", + "要求提供后续变更或 @ 提及智能体", + "要求後續跟進變更", + "要求後續跟進變更或 @ 以標記代理程式" +] + +func isCodexComposerEffectivelyEmpty(_ value: String) -> Bool { + let normalized = value + .replacingOccurrences(of: "\u{200B}", with: "") + .replacingOccurrences(of: "\u{00A0}", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty || codexComposerPlaceholders.contains(normalized) +} + private struct CursorState: Codable { var fileID: UInt64? var offset: UInt64 @@ -156,6 +173,14 @@ final class AutoRetryAgent { private var pollTimer: Timer? private var isRunning = false + var onActivityChange: (() -> Void)? + private(set) var activity: AutoRetryActivity = .watching { + didSet { + guard activity != oldValue else { return } + onActivityChange?() + } + } + init(configStore: ConfigStore) { self.configStore = configStore } @@ -163,7 +188,7 @@ final class AutoRetryAgent { var running: Bool { isRunning } var accessibilityGranted: Bool { AXIsProcessTrusted() } - func recentVisibleThreads(limit: Int = 12) -> [CodexThreadSummary] { + func recentVisibleThreads(limit: Int = 2_000) -> [CodexThreadSummary] { guard let data = try? Data(contentsOf: sessionIndexURL) else { return [] } return parseRecentThreads(data, limit: limit) } @@ -199,6 +224,7 @@ final class AutoRetryAgent { func start() { guard !isRunning else { return } isRunning = true + activity = .watching loadState() pollTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.poll() @@ -449,6 +475,7 @@ final class AutoRetryAgent { let baseline = sessionBaseline(for: failure.threadID) logger.write("scheduled retry \(attempt)/\(retryDelays.count) for \(failure.threadID) in \(Int(delay))s") + activity = .scheduled(attempt: attempt, delaySeconds: Int(delay)) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, @@ -458,6 +485,7 @@ final class AutoRetryAgent { } if self.hasNewActivity(since: baseline) { self.logger.write("cancelled retry for \(failure.threadID): newer user or turn activity detected") + self.activity = .cancelledForNewActivity self.state.retries.removeValue(forKey: failure.threadID) self.saveState() return @@ -523,6 +551,7 @@ final class AutoRetryAgent { logDescription: "retry \(attempt)/\(retryDelays.count)", requiresAgentRunning: true, activityBaseline: activityBaseline, + retryAttempt: attempt, completion: nil ) } @@ -533,10 +562,12 @@ final class AutoRetryAgent { logDescription: String, requiresAgentRunning: Bool, activityBaseline: SessionBaseline, + retryAttempt: Int? = nil, completion: ((Bool, String) -> Void)? ) { guard AXIsProcessTrusted() else { logger.write("cannot submit \(logDescription) for \(threadID): Accessibility permission has not been granted") + if retryAttempt != nil { activity = .submissionBlocked } completion?(false, "accessibility") return } @@ -552,6 +583,7 @@ final class AutoRetryAgent { guard let self, !requiresAgentRunning || self.isRunning else { return } guard let codex = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.codex").first else { self.logger.write("cancelled \(logDescription) for \(threadID): Codex is not running") + if retryAttempt != nil { self.activity = .submissionBlocked } completion?(false, "codexNotRunning") return } @@ -561,11 +593,13 @@ final class AutoRetryAgent { guard !requiresAgentRunning || self.isRunning else { return } guard NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.openai.codex" else { self.logger.write("cancelled \(logDescription) for \(threadID): Codex did not become the frontmost app") + if retryAttempt != nil { self.activity = .submissionBlocked } completion?(false, "codexNotFrontmost") return } guard !self.hasNewActivity(since: activityBaseline) else { self.logger.write("cancelled \(logDescription) for \(threadID): newer user or turn activity detected") + if retryAttempt != nil { self.activity = .cancelledForNewActivity } completion?(false, "newActivity") return } @@ -575,27 +609,46 @@ final class AutoRetryAgent { guard (!requiresAgentRunning || self.isRunning), NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.openai.codex" else { self.logger.write("cancelled \(logDescription) for \(threadID): focus left Codex before submission") + if retryAttempt != nil { self.activity = .submissionBlocked } completion?(false, "focusChanged") return } - guard self.isTargetTaskSelected(threadID: threadID, processID: codex.processIdentifier) else { + guard let targetWindow = self.targetTaskWindow(threadID: threadID, processID: codex.processIdentifier) else { self.logger.write("cancelled \(logDescription) for \(threadID): target task selection could not be verified") + if retryAttempt != nil { self.activity = .submissionBlocked } completion?(false, "targetNotSelected") return } - guard let composer = self.setPromptInFocusedEmptyComposer(prompt, processID: codex.processIdentifier) else { - self.logger.write("cancelled \(logDescription) for \(threadID): focused control was not an empty Codex composer") - completion?(false, "composerNotEmpty") + let preparation = self.preparePrompt(prompt, in: targetWindow, processID: codex.processIdentifier) + guard case let .ready(composer) = preparation else { + switch preparation { + case .notEmpty: + self.logger.write("cancelled \(logDescription) for \(threadID): target composer contains a draft") + if retryAttempt != nil { self.activity = .pausedForDraft } + completion?(false, "composerNotEmpty") + case .notFound: + self.logger.write("cancelled \(logDescription) for \(threadID): target Codex composer was not found") + if retryAttempt != nil { self.activity = .submissionBlocked } + completion?(false, "composerNotFound") + case .writeFailed: + self.logger.write("cancelled \(logDescription) for \(threadID): target Codex composer could not be controlled") + if retryAttempt != nil { self.activity = .submissionBlocked } + completion?(false, "composerWriteFailed") + case .ready: + break + } return } guard !self.hasNewActivity(since: activityBaseline) else { AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, "" as CFString) self.logger.write("cancelled \(logDescription) for \(threadID): newer activity appeared before submission") + if retryAttempt != nil { self.activity = .cancelledForNewActivity } completion?(false, "newActivity") return } self.postKey(code: 36, to: codex.processIdentifier) self.logger.write("submitted \(logDescription) for \(threadID)") + if let retryAttempt { self.activity = .submitted(attempt: retryAttempt) } DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { let confirmed = self.sessionContains(prompt, since: activityBaseline) @@ -630,62 +683,132 @@ final class AutoRetryAgent { CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: false)?.postToPid(processID) } - private func setPromptInFocusedEmptyComposer(_ prompt: String, processID: pid_t) -> AXUIElement? { - let application = AXUIElementCreateApplication(processID) - AXUIElementSetAttributeValue(application, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) - var focusedValue: CFTypeRef? - guard AXUIElementCopyAttributeValue(application, kAXFocusedUIElementAttribute as CFString, &focusedValue) == .success, - let focused = axElement(from: focusedValue) else { return nil } + private enum ComposerPreparation { + case ready(AXUIElement) + case notFound + case notEmpty + case writeFailed + } - var roleValue: CFTypeRef? - guard AXUIElementCopyAttributeValue(focused, kAXRoleAttribute as CFString, &roleValue) == .success, - (roleValue as? String) == kAXTextAreaRole else { return nil } + private func preparePrompt(_ prompt: String, in targetWindow: AXUIElement, processID: pid_t) -> ComposerPreparation { + var visited = 0 + guard let composer = findComposer(in: targetWindow, visited: &visited) else { return .notFound } var existingValue: CFTypeRef? - let valueResult = AXUIElementCopyAttributeValue(focused, kAXValueAttribute as CFString, &existingValue) - guard valueResult == .success, let existing = existingValue as? String else { return nil } - if !existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return nil - } + guard AXUIElementCopyAttributeValue(composer, kAXValueAttribute as CFString, &existingValue) == .success, + let existing = existingValue as? String else { return .writeFailed } + guard isCodexComposerEffectivelyEmpty(existing) else { return .notEmpty } - guard AXUIElementSetAttributeValue(focused, kAXValueAttribute as CFString, prompt as CFString) == .success else { - return nil + let application = AXUIElementCreateApplication(processID) + guard AXUIElementSetAttributeValue(composer, kAXFocusedAttribute as CFString, kCFBooleanTrue) == .success, + AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, prompt as CFString) == .success else { + return .writeFailed } + + var focusedValue: CFTypeRef? var verificationValue: CFTypeRef? - guard AXUIElementCopyAttributeValue(focused, kAXValueAttribute as CFString, &verificationValue) == .success, + let focusedResult = AXUIElementCopyAttributeValue(application, kAXFocusedUIElementAttribute as CFString, &focusedValue) + let valueResult = AXUIElementCopyAttributeValue(composer, kAXValueAttribute as CFString, &verificationValue) + guard focusedResult == .success, + let focused = axElement(from: focusedValue), + CFEqual(focused, composer), + valueResult == .success, (verificationValue as? String) == prompt else { - AXUIElementSetAttributeValue(focused, kAXValueAttribute as CFString, "" as CFString) - return nil + AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, "" as CFString) + return .writeFailed + } + return .ready(composer) + } + + private func findComposer(in element: AXUIElement, visited: inout Int) -> AXUIElement? { + guard visited < 12_000 else { return nil } + visited += 1 + + var roleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleValue) == .success, + (roleValue as? String) == kAXTextAreaRole, + hasComposerSurfaceAncestor(element) { + return element + } + + var childrenValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenValue) == .success, + let children = childrenValue as? [AXUIElement] else { return nil } + for child in children { + if let composer = findComposer(in: child, visited: &visited) { return composer } + } + return nil + } + + private func hasComposerSurfaceAncestor(_ element: AXUIElement) -> Bool { + var current = element + for _ in 0..<8 { + var classesValue: CFTypeRef? + if AXUIElementCopyAttributeValue(current, "AXDOMClassList" as CFString, &classesValue) == .success, + let classes = classesValue as? [String], + classes.contains("composer-surface-chrome") { + return true + } + var parentValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(current, kAXParentAttribute as CFString, &parentValue) == .success, + let parent = axElement(from: parentValue) else { return false } + current = parent } - return focused + return false } - private func isTargetTaskSelected(threadID: String, processID: pid_t) -> Bool { - let threads = recentVisibleThreads(limit: 500) + private func targetTaskWindow(threadID: String, processID: pid_t) -> AXUIElement? { + let threads = recentVisibleThreads(limit: 2_000) guard let target = threads.first(where: { $0.id == threadID }), - threads.filter({ $0.name == target.name }).count == 1 else { return false } + threads.filter({ $0.name == target.name }).count == 1 else { return nil } let application = AXUIElementCreateApplication(processID) AXUIElementSetAttributeValue(application, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) var visited = 0 - return findSelectedTask(named: target.name, in: application, visited: &visited) + guard let selectedTask = findSelectedTask(named: target.name, in: application, visited: &visited) else { return nil } + return containingWindow(of: selectedTask) } - private func findSelectedTask(named targetName: String, in element: AXUIElement, visited: inout Int) -> Bool { - guard visited < 12_000 else { return false } + private func findSelectedTask(named targetName: String, in element: AXUIElement, visited: inout Int) -> AXUIElement? { + guard visited < 12_000 else { return nil } visited += 1 var value: CFTypeRef? if AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &value) == .success, (value as? String) == targetName, - selectedTaskButton(from: element) != nil { - return true + let selectedTask = selectedTaskButton(from: element) { + return selectedTask } var childrenValue: CFTypeRef? guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenValue) == .success, - let children = childrenValue as? [AXUIElement] else { return false } - return children.contains { findSelectedTask(named: targetName, in: $0, visited: &visited) } + let children = childrenValue as? [AXUIElement] else { return nil } + for child in children { + if let selectedTask = findSelectedTask(named: targetName, in: child, visited: &visited) { return selectedTask } + } + return nil + } + + private func containingWindow(of element: AXUIElement) -> AXUIElement? { + var windowValue: CFTypeRef? + if AXUIElementCopyAttributeValue(element, kAXWindowAttribute as CFString, &windowValue) == .success, + let window = axElement(from: windowValue) { + return window + } + + var current = element + for _ in 0..<16 { + var roleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(current, kAXRoleAttribute as CFString, &roleValue) == .success, + (roleValue as? String) == kAXWindowRole { + return current + } + var parentValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(current, kAXParentAttribute as CFString, &parentValue) == .success, + let parent = axElement(from: parentValue) else { return nil } + current = parent + } + return nil } private func selectedTaskButton(from element: AXUIElement) -> AXUIElement? { @@ -835,6 +958,9 @@ func runSelfTest() -> Int32 { isVersion("0.3.0", newerThan: "0.2.9"), !isVersion("0.2.0", newerThan: "0.2.0"), checksumMatches(data: checksumData, checksumText: "\(checksum) Codex-Helper.dmg"), + isCodexComposerEffectivelyEmpty("\nAsk for follow-up changes"), + isCodexComposerEffectivelyEmpty("要求后续变更"), + !isCodexComposerEffectivelyEmpty("另外帮我做"), !retryPromptEnglish.isEmpty, !retryPromptChinese.isEmpty, !testPromptEnglish.isEmpty, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 58ccd50..38c803b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -41,6 +41,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + agent.onActivityChange = { [weak self] in self?.refreshInterface() } configureStatusItem() startStatusRefreshTimer() syncLaunchAtLoginOnFirstRun() @@ -167,7 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } else if !agent.accessibilityGranted { statusTitle = text("Status: Accessibility required", "状态:需要辅助功能权限") } else { - statusTitle = text("Status: Watching Codex", "状态:正在监听 Codex") + statusTitle = autoRetryStatus().title } let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") status.isEnabled = false @@ -370,29 +371,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return } - let threads = agent.recentVisibleThreads() + let threads = agent.recentVisibleThreads(limit: 2_000) guard !threads.isEmpty else { showError(text("No recent visible Codex tasks were found.", "没有找到最近的可见 Codex 任务。")) return } - let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 430, height: 28)) - for thread in threads { - popup.addItem(withTitle: "\(thread.name) — \(thread.id.prefix(8))") - } + let titles = threads.map { "\($0.name) — \($0.id.prefix(8))" } + let taskPicker = NSComboBox(frame: NSRect(x: 0, y: 0, width: 430, height: 28)) + taskPicker.addItems(withObjectValues: titles) + taskPicker.numberOfVisibleItems = 14 + taskPicker.completes = true + taskPicker.selectItem(at: 0) + taskPicker.setAccessibilityLabel(text("Codex task", "Codex 任务")) let alert = NSAlert() alert.messageText = text("Test Auto Retry end to end", "端到端测试自动重试") alert.informativeText = text( - "Choose a task. Codex Helper will open it and submit one clearly marked test message after 3 seconds. This verifies task routing, Accessibility control, text entry, and submission without waiting for a real capacity error.", - "选择一个任务。3 秒后 Codex Helper 会打开它并提交一条明确标记的测试消息,无需等待真实满载错误,即可验证任务定位、辅助功能控制、文字输入和提交。" + "Search or choose any task. Codex Helper will open it and submit one clearly marked test message after 3 seconds. This verifies task routing, Accessibility control, text entry, and submission without waiting for a real capacity error.", + "搜索或选择任意任务。3 秒后 Codex Helper 会打开它并提交一条明确标记的测试消息,无需等待真实满载错误,即可验证任务定位、辅助功能控制、文字输入和提交。" ) - alert.accessoryView = popup + alert.accessoryView = taskPicker alert.addButton(withTitle: text("Run Test", "运行测试")) alert.addButton(withTitle: text("Cancel", "取消")) guard alert.runModal() == .alertFirstButtonReturn else { return } - let selectedIndex = popup.indexOfSelectedItem + let selectedIndex = titles.firstIndex(of: taskPicker.stringValue) ?? taskPicker.indexOfSelectedItem guard threads.indices.contains(selectedIndex), agent.runEndToEndTest(threadID: threads[selectedIndex].id, completion: { [weak self] success, reason in self?.showEndToEndTestResult(success: success, reason: reason) }) else { @@ -485,7 +489,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { "codexNotFrontmost": ("Codex could not become the frontmost app.", "Codex 无法切换到前台。"), "focusChanged": ("Focus left Codex before submission.", "提交前焦点离开了 Codex。"), "targetNotSelected": ("The selected task could not be verified in the Codex sidebar.", "无法在 Codex 侧边栏确认所选任务。"), - "composerNotEmpty": ("The focused Codex composer was not empty or could not be verified.", "Codex 当前输入框不是空白状态,或无法验证。"), + "composerNotEmpty": ("The target Codex composer contains a draft, so Helper left it untouched.", "目标 Codex 输入框中已有草稿,Helper 为避免覆盖而没有提交。"), + "composerNotFound": ("The target Codex composer could not be found.", "无法定位目标 Codex 输入框。"), + "composerWriteFailed": ("The target Codex composer could not be controlled.", "无法控制目标 Codex 输入框。"), "targetNotConfirmed": ("The test prompt was not confirmed in the selected task.", "无法确认测试消息已进入所选任务。") ] let explanation = explanations[reason] ?? ("The test was cancelled before submission.", "测试在提交前已取消。") @@ -669,11 +675,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { "额度和更新仍可使用,但授权前自动重试无法在 Codex 中提交续跑消息。" ) } else { - statusTitle = text("Watching Codex", "正在监听 Codex") - statusDetail = text( - "Auto Retry is ready for selected-model capacity interruptions.", - "自动重试已准备好处理所选模型满载中断。" - ) + let retryStatus = autoRetryStatus() + statusTitle = retryStatus.title + statusDetail = retryStatus.detail } let usageRows = makeDashboardUsageRows(showSparkQuota: config.showSparkQuota) @@ -743,6 +747,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) } + private func autoRetryStatus() -> (title: String, detail: String) { + switch agent.activity { + case .watching: + return ( + text("Watching Codex", "正在监听 Codex"), + text( + "Auto Retry is ready for selected-model capacity interruptions.", + "自动重试已准备好处理所选模型满载中断。" + ) + ) + case let .scheduled(attempt, delaySeconds): + return ( + text("Capacity error detected", "已识别容量错误"), + text( + "Retry \(attempt) will run in \(delaySeconds) seconds.", + "第 \(attempt) 次重试将在 \(delaySeconds) 秒后执行。" + ) + ) + case let .submitted(attempt): + return ( + text("Auto Retry submitted", "已提交自动重试"), + text( + "Retry \(attempt) was sent to the affected Codex task.", + "已向发生中断的 Codex 任务发送第 \(attempt) 次续跑消息。" + ) + ) + case .pausedForDraft: + return ( + text("Retry paused to protect a draft", "已识别,但为保护草稿暂停"), + text( + "The target composer already contains text, so Helper did not overwrite it.", + "目标输入框已有文字,Helper 没有覆盖现有草稿。" + ) + ) + case .submissionBlocked: + return ( + text("Capacity error detected; submission blocked", "已识别容量错误,但提交受阻"), + text( + "Helper could not safely locate or control the target composer. Open the log folder for details.", + "Helper 无法安全定位或控制目标输入框,可打开日志文件夹查看原因。" + ) + ) + case .cancelledForNewActivity: + return ( + text("Retry no longer needed", "重试已取消"), + text( + "The target task received newer activity, so Helper avoided a duplicate submission.", + "目标任务已有新活动,Helper 为避免重复提交而取消了重试。" + ) + ) + } + } + private func makeDashboardUsageRows(showSparkQuota: Bool) -> [DashboardUsageRow] { guard let snapshot = usageService.snapshot else { return [] } return visibleUsageLimits(snapshot.limits, showSparkQuota: showSparkQuota).flatMap { limit in diff --git a/Sources/AutoRetryActivity.swift b/Sources/AutoRetryActivity.swift new file mode 100644 index 0000000..aadf8a2 --- /dev/null +++ b/Sources/AutoRetryActivity.swift @@ -0,0 +1,10 @@ +import Foundation + +enum AutoRetryActivity: Equatable { + case watching + case scheduled(attempt: Int, delaySeconds: Int) + case submitted(attempt: Int) + case pausedForDraft + case submissionBlocked + case cancelledForNewActivity +} diff --git a/docs/releasing.md b/docs/releasing.md index a7e8da7..10f6489 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ Requirements: ```bash SIGNING_IDENTITY="Developer ID Application: Company Name (TEAMID)" \ -VERSION="0.8.1" \ +VERSION="0.8.2" \ ./scripts/package-release.sh ``` @@ -20,9 +20,9 @@ The script verifies the app signature, submits and staples the app, builds and s Publish the resulting DMG and checksum: ```bash -gh release create v0.8.1 \ - dist/Codex-Helper-0.8.1.dmg \ - dist/Codex-Helper-0.8.1.dmg.sha256 \ - --title "Codex Helper v0.8.1" \ +gh release create v0.8.2 \ + dist/Codex-Helper-0.8.2.dmg \ + dist/Codex-Helper-0.8.2.dmg.sha256 \ + --title "Codex Helper v0.8.2" \ --generate-notes ``` diff --git a/scripts/package-release.sh b/scripts/package-release.sh index fc5b5aa..b19f1de 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="${0:A:h:h}" -VERSION="${VERSION:-0.8.1}" +VERSION="${VERSION:-0.8.2}" DIST_DIR="${DIST_DIR:-$ROOT/dist}" BUILD_DIR="$ROOT/.build/notarized" APP_PATH="$BUILD_DIR/Codex Helper.app"