Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.8.2</string>
<string>0.8.3</string>
<key>CFBundleVersion</key>
<string>14</string>
<string>15</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSApplicationCategoryType</key>
Expand Down
142 changes: 122 additions & 20 deletions Sources/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func isCodexComposerEffectivelyEmpty(_ value: String) -> Bool {
return normalized.isEmpty || codexComposerPlaceholders.contains(normalized)
}

func codexComposerDraftToPreserve(_ value: String) -> String? {
isCodexComposerEffectivelyEmpty(value) ? nil : value
}

private struct CursorState: Codable {
var fileID: UInt64?
var offset: UInt64
Expand Down Expand Up @@ -619,13 +623,9 @@ final class AutoRetryAgent {
completion?(false, "targetNotSelected")
return
}
let preparation = self.preparePrompt(prompt, in: targetWindow, processID: codex.processIdentifier)
guard case let .ready(composer) = preparation else {
let preparation = self.preparePrompt(prompt, in: targetWindow)
guard case let .ready(composer, displacedDraft) = 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 }
Expand All @@ -640,7 +640,11 @@ final class AutoRetryAgent {
return
}
guard !self.hasNewActivity(since: activityBaseline) else {
AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, "" as CFString)
AXUIElementSetAttributeValue(
composer,
kAXValueAttribute as CFString,
(displacedDraft ?? "") as CFString
)
self.logger.write("cancelled \(logDescription) for \(threadID): newer activity appeared before submission")
if retryAttempt != nil { self.activity = .cancelledForNewActivity }
completion?(false, "newActivity")
Expand All @@ -650,6 +654,15 @@ final class AutoRetryAgent {
self.logger.write("submitted \(logDescription) for \(threadID)")
if let retryAttempt { self.activity = .submitted(attempt: retryAttempt) }

if let displacedDraft {
self.restoreDraft(
displacedDraft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: 8
)
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
let confirmed = self.sessionContains(prompt, since: activityBaseline)
if confirmed {
Expand Down Expand Up @@ -684,40 +697,127 @@ final class AutoRetryAgent {
}

private enum ComposerPreparation {
case ready(AXUIElement)
case ready(AXUIElement, displacedDraft: String?)
case notFound
case notEmpty
case writeFailed
}

private func preparePrompt(_ prompt: String, in targetWindow: AXUIElement, processID: pid_t) -> ComposerPreparation {
private func preparePrompt(_ prompt: String, in targetWindow: AXUIElement) -> ComposerPreparation {
var visited = 0
guard let composer = findComposer(in: targetWindow, visited: &visited) else { return .notFound }

var existingValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(composer, kAXValueAttribute as CFString, &existingValue) == .success,
let existing = existingValue as? String else { return .writeFailed }
guard isCodexComposerEffectivelyEmpty(existing) else { return .notEmpty }
let displacedDraft = codexComposerDraftToPreserve(existing)

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?
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,
guard valueResult == .success,
(verificationValue as? String) == prompt else {
AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, "" as CFString)
AXUIElementSetAttributeValue(
composer,
kAXValueAttribute as CFString,
(displacedDraft ?? "") as CFString
)
return .writeFailed
}
return .ready(composer)
if displacedDraft != nil {
logger.write("temporarily preserved target composer draft before submission")
}
return .ready(composer, displacedDraft: displacedDraft)
}

private func restoreDraft(
_ draft: String,
afterReplacing prompt: String,
in targetWindow: AXUIElement,
attemptsRemaining: Int
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self else { return }
var visited = 0
guard let composer = self.findComposer(in: targetWindow, visited: &visited) else {
self.retryDraftRestore(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining
)
return
}

var currentValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(composer, kAXValueAttribute as CFString, &currentValue) == .success,
let current = currentValue as? String else {
self.retryDraftRestore(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining
)
return
}

if current == prompt {
self.retryDraftRestore(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining
)
return
}
guard isCodexComposerEffectivelyEmpty(current) else {
self.logger.write("skipped draft restoration because the target composer received newer text")
return
}

guard AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, draft as CFString) == .success else {
self.retryDraftRestore(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining
)
return
}
var restoredValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(composer, kAXValueAttribute as CFString, &restoredValue) == .success,
(restoredValue as? String) == draft else {
self.retryDraftRestore(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining
)
return
}
self.logger.write("restored target composer draft after submission")
}
}

private func retryDraftRestore(
_ draft: String,
afterReplacing prompt: String,
in targetWindow: AXUIElement,
attemptsRemaining: Int
) {
guard attemptsRemaining > 1 else {
logger.write("could not restore target composer draft after submission")
return
}
restoreDraft(
draft,
afterReplacing: prompt,
in: targetWindow,
attemptsRemaining: attemptsRemaining - 1
)
}

private func findComposer(in element: AXUIElement, visited: inout Int) -> AXUIElement? {
Expand Down Expand Up @@ -961,6 +1061,8 @@ func runSelfTest() -> Int32 {
isCodexComposerEffectivelyEmpty("\nAsk for follow-up changes"),
isCodexComposerEffectivelyEmpty("要求后续变更"),
!isCodexComposerEffectivelyEmpty("另外帮我做"),
codexComposerDraftToPreserve("\nAsk for follow-up changes") == nil,
codexComposerDraftToPreserve("另外帮我做") == "另外帮我做",
!retryPromptEnglish.isEmpty,
!retryPromptChinese.isEmpty,
!testPromptEnglish.isEmpty,
Expand Down
13 changes: 2 additions & 11 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let alert = NSAlert()
alert.messageText = text("Test Auto Retry end to end", "端到端测试自动重试")
alert.informativeText = text(
"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 会打开它并提交一条明确标记的测试消息,无需等待真实满载错误,即可验证任务定位、辅助功能控制、文字输入和提交。"
"Search or choose any task. Codex Helper will open it and submit one clearly marked test message after 3 seconds. If the composer contains a draft, Helper will temporarily preserve it and restore it after submission when possible.",
"搜索或选择任意任务。3 秒后 Codex Helper 会打开它并提交一条明确标记的测试消息。如果输入框已有草稿,Helper 会先暂存,并在提交后尽量恢复。"
)
alert.accessoryView = taskPicker
alert.addButton(withTitle: text("Run Test", "运行测试"))
Expand Down Expand Up @@ -489,7 +489,6 @@ 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 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.", "无法确认测试消息已进入所选任务。")
Expand Down Expand Up @@ -773,14 +772,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
"已向发生中断的 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", "已识别容量错误,但提交受阻"),
Expand Down
1 change: 0 additions & 1 deletion Sources/AutoRetryActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ enum AutoRetryActivity: Equatable {
case watching
case scheduled(attempt: Int, delaySeconds: Int)
case submitted(attempt: Int)
case pausedForDraft
case submissionBlocked
case cancelledForNewActivity
}
10 changes: 5 additions & 5 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Requirements:

```bash
SIGNING_IDENTITY="Developer ID Application: Company Name (TEAMID)" \
VERSION="0.8.2" \
VERSION="0.8.3" \
./scripts/package-release.sh
```

Expand All @@ -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.2 \
dist/Codex-Helper-0.8.2.dmg \
dist/Codex-Helper-0.8.2.dmg.sha256 \
--title "Codex Helper v0.8.2" \
gh release create v0.8.3 \
dist/Codex-Helper-0.8.3.dmg \
dist/Codex-Helper-0.8.3.dmg.sha256 \
--title "Codex Helper v0.8.3" \
--generate-notes
```
2 changes: 1 addition & 1 deletion scripts/package-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

ROOT="${0:A:h:h}"
VERSION="${VERSION:-0.8.2}"
VERSION="${VERSION:-0.8.3}"
DIST_DIR="${DIST_DIR:-$ROOT/dist}"
BUILD_DIR="$ROOT/.build/notarized"
APP_PATH="$BUILD_DIR/Codex Helper.app"
Expand Down
Loading