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.0</string>
<string>0.8.1</string>
<key>CFBundleVersion</key>
<string>12</string>
<string>13</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSApplicationCategoryType</key>
Expand Down
149 changes: 147 additions & 2 deletions Sources/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import ApplicationServices
import CryptoKit
import Foundation
import SQLite3

let capacityMessage = "Selected model is at capacity. Please try a different model."
let retryPromptEnglish = "Continue the unfinished task from the failed turn. Do not repeat completed work."
Expand Down Expand Up @@ -58,6 +59,8 @@ private struct RetryState: Codable {

private struct PersistedState: Codable {
var cursor = CursorState(fileID: nil, offset: 0)
var databaseFileID: UInt64?
var databaseCursor: Int64?
var retries: [String: RetryState] = [:]
}

Expand All @@ -80,6 +83,26 @@ func parseCapacityFailureLine(_ line: String) -> CapacityFailure? {
return CapacityFailure(threadID: threadID, timestamp: timestamp)
}

func parseCapacityFailureRecord(_ body: String, threadID: String?, timestamp: Date) -> CapacityFailure? {
guard body.contains("Turn error: \(capacityMessage)") else { return nil }

if let threadID, threadID.range(of: #"^[0-9a-fA-F-]{36}$"#, options: .regularExpression) != nil {
return CapacityFailure(threadID: threadID, timestamp: timestamp)
}

let patterns = [#"thread\.id=[0-9a-fA-F-]{36}"#, #"thread_id=[0-9a-fA-F-]{36}"#]
for pattern in patterns {
guard let expression = try? NSRegularExpression(pattern: pattern) else { continue }
let range = NSRange(body.startIndex..<body.endIndex, in: body)
guard let match = expression.matches(in: body, range: range).last,
let matchRange = Range(match.range, in: body) else { continue }
let token = String(body[matchRange])
guard let separator = token.firstIndex(of: "=") else { continue }
return CapacityFailure(threadID: String(token[token.index(after: separator)...]), timestamp: timestamp)
}
return nil
}

private struct SessionBaseline {
let path: URL?
let offset: UInt64
Expand Down Expand Up @@ -129,6 +152,7 @@ final class AutoRetryAgent {
private lazy var logger = AgentLogger(logURL: configStore.supportURL.appendingPathComponent("agent.log"))
private var state = PersistedState()
private var partialLine = ""
private var lastDatabaseCursorSaveAt = Date.distantPast
private var pollTimer: Timer?
private var isRunning = false

Expand Down Expand Up @@ -235,8 +259,13 @@ final class AutoRetryAgent {
}

private func poll() {
guard isRunning,
let attributes = try? fileManager.attributesOfItem(atPath: logURL.path),
guard isRunning else { return }
pollDatabase()
pollLegacyLog()
}

private func pollLegacyLog() {
guard let attributes = try? fileManager.attributesOfItem(atPath: logURL.path),
let sizeNumber = attributes[.size] as? NSNumber else {
return
}
Expand Down Expand Up @@ -272,6 +301,107 @@ final class AutoRetryAgent {
}
}

private func pollDatabase() {
guard let databaseURL = currentLogsDatabase(),
let attributes = try? fileManager.attributesOfItem(atPath: databaseURL.path),
let fileID = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value,
let database = openReadOnlyDatabase(at: databaseURL) else { return }
defer { sqlite3_close(database) }

guard let highWatermark = databaseHighWatermark(database) else { return }
if state.databaseFileID == nil {
state.databaseFileID = fileID
state.databaseCursor = highWatermark
lastDatabaseCursorSaveAt = Date()
saveState()
logger.write("monitoring current Codex event database \(databaseURL.lastPathComponent)")
return
}
if state.databaseFileID != fileID || highWatermark < (state.databaseCursor ?? 0) {
state.databaseFileID = fileID
state.databaseCursor = 0
}

let cursor = state.databaseCursor ?? highWatermark
guard highWatermark > cursor else { return }
let failures = databaseCapacityFailures(database, after: cursor, through: highWatermark)
state.databaseCursor = highWatermark

let now = Date()
if !failures.isEmpty || now.timeIntervalSince(lastDatabaseCursorSaveAt) >= 60 {
lastDatabaseCursorSaveAt = now
saveState()
}
for failure in failures {
handle(failure)
}
}

private func currentLogsDatabase() -> URL? {
guard let urls = try? fileManager.contentsOfDirectory(
at: codexHome,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return nil }
return urls.compactMap { url -> (URL, Int)? in
let name = url.lastPathComponent
guard name.hasPrefix("logs_"), name.hasSuffix(".sqlite"),
let version = Int(name.dropFirst(5).dropLast(7)) else { return nil }
return (url, version)
}.max(by: { $0.1 < $1.1 })?.0
}

private func openReadOnlyDatabase(at url: URL) -> OpaquePointer? {
var database: OpaquePointer?
let result = sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil)
guard result == SQLITE_OK, let database else {
if let database { sqlite3_close(database) }
return nil
}
sqlite3_busy_timeout(database, 150)
return database
}

private func databaseHighWatermark(_ database: OpaquePointer) -> Int64? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, "SELECT COALESCE(MAX(id), 0) FROM logs", -1, &statement, nil) == SQLITE_OK,
let statement else { return nil }
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
return sqlite3_column_int64(statement, 0)
}

private func databaseCapacityFailures(
_ database: OpaquePointer,
after cursor: Int64,
through highWatermark: Int64
) -> [CapacityFailure] {
let sql = """
SELECT ts, thread_id, feedback_log_body
FROM logs
WHERE id > ? AND id <= ? AND target = 'codex_core::session::turn'
ORDER BY id ASC
"""
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK,
let statement else { return [] }
defer { sqlite3_finalize(statement) }
sqlite3_bind_int64(statement, 1, cursor)
sqlite3_bind_int64(statement, 2, highWatermark)

var failures: [CapacityFailure] = []
while sqlite3_step(statement) == SQLITE_ROW {
guard let bodyPointer = sqlite3_column_text(statement, 2) else { continue }
let body = String(cString: bodyPointer)
let threadID = sqlite3_column_text(statement, 1).map { String(cString: $0) }
let timestamp = Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 0)))
if let failure = parseCapacityFailureRecord(body, threadID: threadID, timestamp: timestamp) {
failures.append(failure)
}
}
return failures
}

private func consume(_ data: Data) {
guard let text = String(data: data, encoding: .utf8) else { return }
let combined = partialLine + text
Expand All @@ -292,6 +422,12 @@ final class AutoRetryAgent {
return
}

if let existing = state.retries[failure.threadID],
abs(failure.timestamp.timeIntervalSince(existing.lastErrorAt)) < 1 {
logger.write("ignored duplicate capacity failure for \(failure.threadID)")
return
}

var retryState = state.retries[failure.threadID] ?? RetryState(attempts: 0, lastErrorAt: failure.timestamp, generation: 0)
if failure.timestamp.timeIntervalSince(retryState.lastErrorAt) > attemptResetInterval {
retryState.attempts = 0
Expand Down Expand Up @@ -595,6 +731,11 @@ final class AutoRetryAgent {
func runSelfTest() -> Int32 {
let sample = "2026-07-13T12:00:00.123456Z INFO session_loop{thread_id=019f59b0-c8ec-7cf1-88be-4e6247938d01}: Turn error: \(capacityMessage)"
let failure = parseCapacityFailureLine(sample)
let databaseFailure = parseCapacityFailureRecord(
"turn{thread.id=019f6017-6542-7c50-a6b9-b87406def81a}:run_turn: Turn error: \(capacityMessage)",
threadID: nil,
timestamp: Date(timeIntervalSince1970: 100)
)
let activity = #"{"type":"event_msg","payload":{"type":"user_message"}}"#
let legacyConfig = #"{"language":"zh"}"#.data(using: .utf8)!
let decodedConfig = try? JSONDecoder().decode(AgentConfig.self, from: legacyConfig)
Expand Down Expand Up @@ -650,6 +791,10 @@ func runSelfTest() -> Int32 {
let checksumData = Data("codex-helper".utf8)
let checksum = SHA256.hash(data: checksumData).map { String(format: "%02x", $0) }.joined()
guard failure?.threadID == "019f59b0-c8ec-7cf1-88be-4e6247938d01",
databaseFailure == CapacityFailure(
threadID: "019f6017-6542-7c50-a6b9-b87406def81a",
timestamp: Date(timeIntervalSince1970: 100)
),
containsNewTurnActivity(activity),
decodedConfig == AgentConfig(language: "zh"),
decodedHiddenQuotaConfig?.showQuotaInMenuBar == false,
Expand Down
4 changes: 2 additions & 2 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ flowchart LR

## Detection

The native Swift agent tails `~/.codex/log/codex-tui.log`. It looks for the exact capacity message and extracts the UUID from `thread_id=...`. The first launch starts at end-of-file, so old failures are not replayed.
The native Swift agent reads new `codex_core::session::turn` records from Codex Desktop's current `~/.codex/logs_*.sqlite` event database. It looks for the exact capacity message and uses the record's task UUID. The first launch starts at the current database high-water mark, so old failures are not replayed. The legacy `~/.codex/log/codex-tui.log` tail remains as a compatibility fallback for older Codex builds.

The task ID must also exist in `~/.codex/session_index.jsonl`. This deliberately excludes hidden subagent sessions.

Expand Down Expand Up @@ -61,7 +61,7 @@ The updater checks `makerjackie/codex-helper` GitHub Releases at most once per d
- No backend service or telemetry. Network requests go directly to official OpenAI feeds and the project's GitHub Releases API.
- No project files are read.
- No conversation content is retained.
- Persistent state contains log cursor offsets, task IDs, timestamps, and retry counters.
- Persistent state contains event cursors, task IDs, timestamps, and retry counters.
- Accessibility permission is used only to synthesize retry keystrokes targeted to the Codex process, after confirming Codex is frontmost. Launch, quota refresh, and automated tests never prompt for permission; only an explicit user action does.
- GitHub Release builds use Developer ID signing, hardened runtime, Apple notarization, and stapled tickets. Source builds installed with `install.sh` use local ad-hoc signing.

Expand Down
4 changes: 2 additions & 2 deletions docs/how-it-works.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ flowchart LR

## 错误检测

原生 Swift 助手持续读取 `~/.codex/log/codex-tui.log` 的新增内容,匹配精确的容量错误,并从 `thread_id=...` 提取 UUID。首次启动会从文件末尾开始,因此不会重放历史错误。
原生 Swift 助手会读取 Codex Desktop 当前 `~/.codex/logs_*.sqlite` 事件数据库中新产生的 `codex_core::session::turn` 记录,匹配精确的容量错误,并使用记录中的任务 UUID。首次启动会从数据库当前的最新位置开始,因此不会重放历史错误;旧版 Codex 使用的 `~/.codex/log/codex-tui.log` 仍作为兼容后备来源

任务 ID 还必须存在于 `~/.codex/session_index.jsonl`,从而主动排除隐藏的子代理会话。

Expand Down Expand Up @@ -61,7 +61,7 @@ Codex Helper 会先验证随 App 提供的 `codex` 可执行文件属于 OpenAI
- 没有后端服务或遥测;网络请求只会直接访问 OpenAI 官方公开 RSS 和项目 GitHub Releases API。
- 不读取项目文件。
- 不保存对话内容。
- 持久化状态只包含日志游标、任务 ID、时间戳和重试次数。
- 持久化状态只包含事件游标、任务 ID、时间戳和重试次数。
- 辅助功能权限只用于向 Codex 进程定向发送重试键盘操作,并且发送前会确认 Codex 位于前台。App 启动、额度刷新与自动化测试都不会主动弹出授权请求;只有用户点击授权入口时才请求。
- GitHub Release 版本使用 Developer ID 签名、Hardened Runtime、Apple 公证和 stapled ticket;通过 `install.sh` 构建的源码版本使用本机 ad-hoc 签名。

Expand Down
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.0" \
VERSION="0.8.1" \
./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.0 \
dist/Codex-Helper-0.8.0.dmg \
dist/Codex-Helper-0.8.0.dmg.sha256 \
--title "Codex Helper v0.8.0" \
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" \
--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.0}"
VERSION="${VERSION:-0.8.1}"
DIST_DIR="${DIST_DIR:-$ROOT/dist}"
BUILD_DIR="$ROOT/.build/notarized"
APP_PATH="$BUILD_DIR/Codex Helper.app"
Expand Down
Loading