From 9ecccd5a46bc2ff914a8b4294b8e4f18c9515549 Mon Sep 17 00:00:00 2001
From: jackiexiao <707610215@qq.com>
Date: Thu, 16 Jul 2026 17:33:48 +0800
Subject: [PATCH] fix: monitor current Codex capacity events
---
Resources/Info.plist | 4 +-
Sources/Agent.swift | 149 ++++++++++++++++++++++++++++++++++++-
docs/how-it-works.md | 4 +-
docs/how-it-works.zh-CN.md | 4 +-
docs/releasing.md | 10 +--
scripts/package-release.sh | 2 +-
6 files changed, 159 insertions(+), 14 deletions(-)
diff --git a/Resources/Info.plist b/Resources/Info.plist
index ae271ca..16f0ea0 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.8.0
+ 0.8.1
CFBundleVersion
- 12
+ 13
LSMinimumSystemVersion
13.0
LSApplicationCategoryType
diff --git a/Sources/Agent.swift b/Sources/Agent.swift
index 07ee288..cb97836 100644
--- a/Sources/Agent.swift
+++ b/Sources/Agent.swift
@@ -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."
@@ -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] = [:]
}
@@ -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..
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
@@ -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
@@ -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)
@@ -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,
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index 75dd132..6c17c2f 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -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.
@@ -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.
diff --git a/docs/how-it-works.zh-CN.md b/docs/how-it-works.zh-CN.md
index a94d7ea..958d593 100644
--- a/docs/how-it-works.zh-CN.md
+++ b/docs/how-it-works.zh-CN.md
@@ -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`,从而主动排除隐藏的子代理会话。
@@ -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 签名。
diff --git a/docs/releasing.md b/docs/releasing.md
index 146d888..a7e8da7 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.0" \
+VERSION="0.8.1" \
./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.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
```
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
index bfc8105..fc5b5aa 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.0}"
+VERSION="${VERSION:-0.8.1}"
DIST_DIR="${DIST_DIR:-$ROOT/dist}"
BUILD_DIR="$ROOT/.build/notarized"
APP_PATH="$BUILD_DIR/Codex Helper.app"