codex-flow 2.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct AccountQuotaWindow: Identifiable {
|
|
4
|
+
public let id: String
|
|
5
|
+
public let slot: String?
|
|
6
|
+
public let durationMinutes: Int?
|
|
7
|
+
public let usedPercent: Double?
|
|
8
|
+
public let resetsAt: Date?
|
|
9
|
+
|
|
10
|
+
public init(
|
|
11
|
+
id: String,
|
|
12
|
+
slot: String? = nil,
|
|
13
|
+
durationMinutes: Int?,
|
|
14
|
+
usedPercent: Double?,
|
|
15
|
+
resetsAt: Date?
|
|
16
|
+
) {
|
|
17
|
+
self.id = id
|
|
18
|
+
self.slot = slot
|
|
19
|
+
self.durationMinutes = durationMinutes
|
|
20
|
+
self.usedPercent = usedPercent
|
|
21
|
+
self.resetsAt = resetsAt
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public var remainingPercent: Double? {
|
|
25
|
+
guard let usedPercent else { return nil }
|
|
26
|
+
return max(0, min(100, 100 - usedPercent))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public var displayName: String {
|
|
30
|
+
guard let mins = durationMinutes else { return L("Usage window", "额度窗口") }
|
|
31
|
+
switch mins {
|
|
32
|
+
case 300: return L("5 hour limit", "5 小时额度")
|
|
33
|
+
case 10080: return L("Weekly limit", "每周额度")
|
|
34
|
+
default:
|
|
35
|
+
if mins < 60 { return "\(mins)m" }
|
|
36
|
+
if mins < 1440 { return "\(mins / 60)h" }
|
|
37
|
+
return "\(mins / 1440)d"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public var compactName: String {
|
|
42
|
+
guard let mins = durationMinutes else { return "—" }
|
|
43
|
+
if mins == 300 { return "5h" }
|
|
44
|
+
if mins == 10080 { return L("Weekly", "每周") }
|
|
45
|
+
if mins < 60 { return "\(mins)m" }
|
|
46
|
+
if mins < 1440 { return "\(mins / 60)h" }
|
|
47
|
+
return "\(mins / 1440)d"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public struct AccountResetCredit: Identifiable {
|
|
52
|
+
public let id: String
|
|
53
|
+
public let status: String?
|
|
54
|
+
public let expiresAt: Date?
|
|
55
|
+
public let title: String?
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public struct AccountSnapshot {
|
|
59
|
+
public let accountId: String?
|
|
60
|
+
public let accountType: String?
|
|
61
|
+
public let email: String?
|
|
62
|
+
public let planType: String?
|
|
63
|
+
public let requiresOpenAIAuth: Bool?
|
|
64
|
+
public let quotaWindows: [AccountQuotaWindow]
|
|
65
|
+
public let resetCreditCount: Int?
|
|
66
|
+
public let resetCredits: [AccountResetCredit]
|
|
67
|
+
public let hasCredits: Bool?
|
|
68
|
+
public let unlimitedCredits: Bool?
|
|
69
|
+
public let creditsBalance: String?
|
|
70
|
+
public let spendControlReached: Bool?
|
|
71
|
+
public let rateLimitReachedType: String?
|
|
72
|
+
public let fetchedAt: Date
|
|
73
|
+
|
|
74
|
+
public init(
|
|
75
|
+
accountId: String? = nil,
|
|
76
|
+
accountType: String?,
|
|
77
|
+
email: String?,
|
|
78
|
+
planType: String?,
|
|
79
|
+
requiresOpenAIAuth: Bool?,
|
|
80
|
+
quotaWindows: [AccountQuotaWindow],
|
|
81
|
+
resetCreditCount: Int?,
|
|
82
|
+
resetCredits: [AccountResetCredit],
|
|
83
|
+
hasCredits: Bool?,
|
|
84
|
+
unlimitedCredits: Bool?,
|
|
85
|
+
creditsBalance: String?,
|
|
86
|
+
spendControlReached: Bool?,
|
|
87
|
+
rateLimitReachedType: String?,
|
|
88
|
+
fetchedAt: Date
|
|
89
|
+
) {
|
|
90
|
+
self.accountId = accountId
|
|
91
|
+
self.accountType = accountType
|
|
92
|
+
self.email = email
|
|
93
|
+
self.planType = planType
|
|
94
|
+
self.requiresOpenAIAuth = requiresOpenAIAuth
|
|
95
|
+
self.quotaWindows = quotaWindows
|
|
96
|
+
self.resetCreditCount = resetCreditCount
|
|
97
|
+
self.resetCredits = resetCredits
|
|
98
|
+
self.hasCredits = hasCredits
|
|
99
|
+
self.unlimitedCredits = unlimitedCredits
|
|
100
|
+
self.creditsBalance = creditsBalance
|
|
101
|
+
self.spendControlReached = spendControlReached
|
|
102
|
+
self.rateLimitReachedType = rateLimitReachedType
|
|
103
|
+
self.fetchedAt = fetchedAt
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public var fiveHourWindow: AccountQuotaWindow? {
|
|
107
|
+
quotaWindows.first { $0.durationMinutes == 300 }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public var weeklyWindow: AccountQuotaWindow? {
|
|
111
|
+
quotaWindows.first { $0.durationMinutes == 10080 }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public var orderedWindows: [AccountQuotaWindow] {
|
|
115
|
+
var result: [AccountQuotaWindow] = []
|
|
116
|
+
if let fiveHourWindow { result.append(fiveHourWindow) }
|
|
117
|
+
if let weeklyWindow { result.append(weeklyWindow) }
|
|
118
|
+
let knownIds = Set(result.map(\.id))
|
|
119
|
+
result.append(contentsOf: quotaWindows.filter { !knownIds.contains($0.id) }.sorted {
|
|
120
|
+
($0.durationMinutes ?? Int.max) < ($1.durationMinutes ?? Int.max)
|
|
121
|
+
})
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// True when Codex returned no account, quota, credit, or auth facts. A
|
|
126
|
+
/// successful but empty response is distinct from a transport/RPC error so
|
|
127
|
+
/// the Account view can render an explicit empty state.
|
|
128
|
+
public var isEmpty: Bool {
|
|
129
|
+
accountId == nil && accountType == nil && email == nil && planType == nil &&
|
|
130
|
+
requiresOpenAIAuth == nil && quotaWindows.isEmpty &&
|
|
131
|
+
resetCreditCount == nil && resetCredits.isEmpty &&
|
|
132
|
+
hasCredits == nil && unlimitedCredits == nil &&
|
|
133
|
+
creditsBalance == nil && spendControlReached == nil &&
|
|
134
|
+
rateLimitReachedType == nil
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public var nearestResetCreditExpiry: Date? {
|
|
138
|
+
resetCredits.compactMap(\.expiresAt).filter { $0 > Date() }.min()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private final class AccountAppServerRPC {
|
|
143
|
+
private final class Pending {
|
|
144
|
+
let semaphore: DispatchSemaphore
|
|
145
|
+
var result: [String: Any]?
|
|
146
|
+
var error: Error?
|
|
147
|
+
|
|
148
|
+
init() {
|
|
149
|
+
semaphore = DispatchSemaphore(value: 0)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private enum RPCError: LocalizedError {
|
|
154
|
+
case deadlineExceeded(String)
|
|
155
|
+
case processExited(Int32, String)
|
|
156
|
+
case transport(String, String)
|
|
157
|
+
case invalidResponse(String, String)
|
|
158
|
+
case server(String, String?, String, String?)
|
|
159
|
+
|
|
160
|
+
var errorDescription: String? {
|
|
161
|
+
switch self {
|
|
162
|
+
case let .deadlineExceeded(method):
|
|
163
|
+
return "Codex \(method) timed out before the account request completed."
|
|
164
|
+
case let .processExited(status, detail):
|
|
165
|
+
let suffix = detail.isEmpty ? "" : " — \(detail)"
|
|
166
|
+
return "Codex app-server exited with status \(status)\(suffix)."
|
|
167
|
+
case let .transport(method, detail):
|
|
168
|
+
return "Codex \(method) transport failed: \(detail)."
|
|
169
|
+
case let .invalidResponse(method, detail):
|
|
170
|
+
return "Codex \(method) returned an invalid response: \(detail)."
|
|
171
|
+
case let .server(method, code, message, data):
|
|
172
|
+
var value = "Codex \(method) failed"
|
|
173
|
+
if let code, !code.isEmpty { value += " (\(code))" }
|
|
174
|
+
value += ": \(message)"
|
|
175
|
+
if let data, !data.isEmpty { value += " — \(data)" }
|
|
176
|
+
return value
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private let process = Process()
|
|
182
|
+
private let input = Pipe()
|
|
183
|
+
private let output = Pipe()
|
|
184
|
+
private let errorOutput = Pipe()
|
|
185
|
+
private let lock = NSLock()
|
|
186
|
+
private let stderrReadLock = NSLock()
|
|
187
|
+
private let deadline: Date
|
|
188
|
+
private var nextId = 1
|
|
189
|
+
private var pending: [Int: Pending] = [:]
|
|
190
|
+
private var requestMethods: [Int: String] = [:]
|
|
191
|
+
private var buffer = Data()
|
|
192
|
+
private var stderrBuffer = Data()
|
|
193
|
+
private var started = false
|
|
194
|
+
private var terminalError: Error?
|
|
195
|
+
|
|
196
|
+
init(deadline: Date) throws {
|
|
197
|
+
self.deadline = deadline
|
|
198
|
+
Self.configureProcess(process)
|
|
199
|
+
process.standardInput = input
|
|
200
|
+
process.standardOutput = output
|
|
201
|
+
process.standardError = errorOutput
|
|
202
|
+
|
|
203
|
+
output.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
204
|
+
let data = handle.availableData
|
|
205
|
+
guard let self else { return }
|
|
206
|
+
if data.isEmpty {
|
|
207
|
+
// A child can close stdout before it finishes flushing stderr;
|
|
208
|
+
// let the termination handler capture the final CLI/auth text.
|
|
209
|
+
// If it has already exited, mark EOF immediately so waiters do
|
|
210
|
+
// not depend on a readability callback ordering.
|
|
211
|
+
if !self.process.isRunning {
|
|
212
|
+
self.markTerminal(nil)
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
self.receive(data)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
errorOutput.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
219
|
+
self?.receiveStderr(from: handle)
|
|
220
|
+
}
|
|
221
|
+
process.terminationHandler = { [weak self] process in
|
|
222
|
+
self?.drainStderr()
|
|
223
|
+
let detail = self?.stderrText() ?? ""
|
|
224
|
+
self?.markTerminal(RPCError.processExited(process.terminationStatus, detail))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
do {
|
|
228
|
+
try process.run()
|
|
229
|
+
lock.lock()
|
|
230
|
+
started = true
|
|
231
|
+
lock.unlock()
|
|
232
|
+
} catch {
|
|
233
|
+
output.fileHandleForReading.readabilityHandler = nil
|
|
234
|
+
errorOutput.fileHandleForReading.readabilityHandler = nil
|
|
235
|
+
throw NSError(
|
|
236
|
+
domain: "FlowPilot.Account",
|
|
237
|
+
code: 1,
|
|
238
|
+
userInfo: [NSLocalizedDescriptionKey: "Unable to start Codex app-server: \(error.localizedDescription)"]
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
deinit { close() }
|
|
244
|
+
|
|
245
|
+
func initialize() throws {
|
|
246
|
+
_ = try request(
|
|
247
|
+
method: "initialize",
|
|
248
|
+
params: [
|
|
249
|
+
"clientInfo": ["name": "codex-flow", "title": "FlowPilot Account", "version": "1"],
|
|
250
|
+
"capabilities": ["experimentalApi": true]
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
try notify(method: "initialized", params: nil)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func request(method: String, params: [String: Any]?, timeout: TimeInterval = 4.0) throws -> [String: Any] {
|
|
257
|
+
let item = Pending()
|
|
258
|
+
let id: Int
|
|
259
|
+
|
|
260
|
+
lock.lock()
|
|
261
|
+
guard started, process.isRunning else {
|
|
262
|
+
let error = terminalError ?? RPCError.transport(method, "process is not running")
|
|
263
|
+
lock.unlock()
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
id = nextId
|
|
267
|
+
nextId += 1
|
|
268
|
+
pending[id] = item
|
|
269
|
+
requestMethods[id] = method
|
|
270
|
+
lock.unlock()
|
|
271
|
+
|
|
272
|
+
var payload: [String: Any] = ["id": id, "method": method]
|
|
273
|
+
if let params { payload["params"] = params }
|
|
274
|
+
|
|
275
|
+
do {
|
|
276
|
+
try send(payload, method: method, timeout: timeout)
|
|
277
|
+
} catch {
|
|
278
|
+
lock.lock()
|
|
279
|
+
pending.removeValue(forKey: id)
|
|
280
|
+
requestMethods.removeValue(forKey: id)
|
|
281
|
+
lock.unlock()
|
|
282
|
+
throw error
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let remaining = min(timeout, max(0, deadline.timeIntervalSinceNow))
|
|
286
|
+
guard remaining > 0 else {
|
|
287
|
+
lock.lock()
|
|
288
|
+
pending.removeValue(forKey: id)
|
|
289
|
+
requestMethods.removeValue(forKey: id)
|
|
290
|
+
lock.unlock()
|
|
291
|
+
throw stopAfterTimeout()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
guard item.semaphore.wait(timeout: .now() + remaining) == .success else {
|
|
295
|
+
lock.lock()
|
|
296
|
+
pending.removeValue(forKey: id)
|
|
297
|
+
requestMethods.removeValue(forKey: id)
|
|
298
|
+
let detail = terminalError
|
|
299
|
+
lock.unlock()
|
|
300
|
+
let timeoutError = stopAfterTimeout()
|
|
301
|
+
if let detail { throw detail }
|
|
302
|
+
throw timeoutError
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
lock.lock()
|
|
306
|
+
let response = pending.removeValue(forKey: id)
|
|
307
|
+
requestMethods.removeValue(forKey: id)
|
|
308
|
+
let result = response?.result
|
|
309
|
+
let responseError = response?.error
|
|
310
|
+
lock.unlock()
|
|
311
|
+
|
|
312
|
+
if let responseError { throw responseError }
|
|
313
|
+
guard let result else {
|
|
314
|
+
throw RPCError.invalidResponse(method, "missing result")
|
|
315
|
+
}
|
|
316
|
+
return result
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func notify(method: String, params: [String: Any]?) throws {
|
|
320
|
+
var payload: [String: Any] = ["method": method]
|
|
321
|
+
if let params { payload["params"] = params }
|
|
322
|
+
try send(payload, method: method, timeout: 1.0)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
func close() {
|
|
326
|
+
output.fileHandleForReading.readabilityHandler = nil
|
|
327
|
+
errorOutput.fileHandleForReading.readabilityHandler = nil
|
|
328
|
+
|
|
329
|
+
lock.lock()
|
|
330
|
+
let wasStarted = started
|
|
331
|
+
started = false
|
|
332
|
+
if terminalError == nil, wasStarted {
|
|
333
|
+
terminalError = RPCError.transport("app-server", "connection closed")
|
|
334
|
+
}
|
|
335
|
+
let waiting = Array(pending.values)
|
|
336
|
+
let failure = terminalError
|
|
337
|
+
for item in waiting {
|
|
338
|
+
if item.error == nil {
|
|
339
|
+
item.error = failure ?? RPCError.transport("app-server", "connection closed")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
lock.unlock()
|
|
343
|
+
|
|
344
|
+
for item in waiting {
|
|
345
|
+
item.semaphore.signal()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Closing the writer also releases a synchronous pipe write that may
|
|
349
|
+
// be blocked because a broken/aborted child stopped reading stdin.
|
|
350
|
+
input.fileHandleForWriting.closeFile()
|
|
351
|
+
|
|
352
|
+
if process.isRunning {
|
|
353
|
+
process.terminate()
|
|
354
|
+
let grace = max(0, min(0.2, deadline.timeIntervalSinceNow))
|
|
355
|
+
if grace > 0 {
|
|
356
|
+
let end = Date().addingTimeInterval(grace)
|
|
357
|
+
while process.isRunning && Date() < end {
|
|
358
|
+
Thread.sleep(forTimeInterval: 0.01)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if process.isRunning {
|
|
362
|
+
_ = kill(process.processIdentifier, SIGKILL)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if wasStarted {
|
|
366
|
+
// Process.waitUntilExit() is intentionally moved off the caller's
|
|
367
|
+
// path. A broken child must never make AccountSnapshotService's
|
|
368
|
+
// cleanup unbounded after the request deadline has expired. The
|
|
369
|
+
// wait also gives a naturally exited child time to finish its
|
|
370
|
+
// termination handler before pipe handles are closed.
|
|
371
|
+
waitForExit(timeout: 0.2)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
output.fileHandleForReading.closeFile()
|
|
375
|
+
stderrReadLock.lock()
|
|
376
|
+
errorOutput.fileHandleForReading.closeFile()
|
|
377
|
+
stderrReadLock.unlock()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private func waitForExit(timeout: TimeInterval) {
|
|
381
|
+
guard process.processIdentifier > 0 else { return }
|
|
382
|
+
let completed = DispatchSemaphore(value: 0)
|
|
383
|
+
let child = process
|
|
384
|
+
DispatchQueue.global(qos: .utility).async {
|
|
385
|
+
child.waitUntilExit()
|
|
386
|
+
completed.signal()
|
|
387
|
+
}
|
|
388
|
+
let boundedTimeout = max(0, timeout)
|
|
389
|
+
_ = completed.wait(timeout: .now() + boundedTimeout)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private func send(_ object: [String: Any], method: String, timeout: TimeInterval) throws {
|
|
393
|
+
guard JSONSerialization.isValidJSONObject(object),
|
|
394
|
+
var data = try? JSONSerialization.data(withJSONObject: object) else {
|
|
395
|
+
throw RPCError.invalidResponse(method, "request is not valid JSON")
|
|
396
|
+
}
|
|
397
|
+
data.append(0x0A)
|
|
398
|
+
|
|
399
|
+
lock.lock()
|
|
400
|
+
let isStarted = started && process.isRunning
|
|
401
|
+
let knownError = terminalError
|
|
402
|
+
lock.unlock()
|
|
403
|
+
guard isStarted else {
|
|
404
|
+
throw knownError ?? RPCError.transport(method, "process is not running")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let writeLock = NSLock()
|
|
408
|
+
var writeResult: Result<Void, Error>?
|
|
409
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
410
|
+
DispatchQueue.global(qos: .userInitiated).async { [input] in
|
|
411
|
+
do {
|
|
412
|
+
try input.fileHandleForWriting.write(contentsOf: data)
|
|
413
|
+
writeLock.lock()
|
|
414
|
+
writeResult = .success(())
|
|
415
|
+
writeLock.unlock()
|
|
416
|
+
} catch {
|
|
417
|
+
writeLock.lock()
|
|
418
|
+
writeResult = .failure(error)
|
|
419
|
+
writeLock.unlock()
|
|
420
|
+
}
|
|
421
|
+
semaphore.signal()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let remaining = min(timeout, max(0, deadline.timeIntervalSinceNow))
|
|
425
|
+
guard remaining > 0, semaphore.wait(timeout: .now() + remaining) == .success else {
|
|
426
|
+
throw stopAfterTimeout()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
writeLock.lock()
|
|
430
|
+
let result = writeResult
|
|
431
|
+
writeLock.unlock()
|
|
432
|
+
if case let .failure(error) = result {
|
|
433
|
+
let detail = stderrText()
|
|
434
|
+
throw RPCError.transport(method, detail.isEmpty ? error.localizedDescription : detail)
|
|
435
|
+
}
|
|
436
|
+
guard result != nil else {
|
|
437
|
+
throw RPCError.transport(method, "pipe write did not complete")
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private func receive(_ data: Data) {
|
|
442
|
+
lock.lock()
|
|
443
|
+
buffer.append(data)
|
|
444
|
+
let newline = Data([0x0A])
|
|
445
|
+
var lines: [Data] = []
|
|
446
|
+
while let range = buffer.range(of: newline) {
|
|
447
|
+
lines.append(buffer.subdata(in: buffer.startIndex..<range.lowerBound))
|
|
448
|
+
buffer.removeSubrange(buffer.startIndex...range.lowerBound)
|
|
449
|
+
}
|
|
450
|
+
lock.unlock()
|
|
451
|
+
|
|
452
|
+
for line in lines where !line.isEmpty {
|
|
453
|
+
guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else {
|
|
454
|
+
// Keep protocol noise available to the eventual transport
|
|
455
|
+
// error rather than silently discarding it.
|
|
456
|
+
lock.lock()
|
|
457
|
+
if stderrBuffer.isEmpty { stderrBuffer.append(line) }
|
|
458
|
+
lock.unlock()
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
guard let idNumber = object["id"] as? NSNumber else { continue }
|
|
462
|
+
let id = idNumber.intValue
|
|
463
|
+
lock.lock()
|
|
464
|
+
guard let item = pending[id] else {
|
|
465
|
+
lock.unlock()
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
if let errorObject = object["error"] as? [String: Any] {
|
|
469
|
+
item.error = rpcError(errorObject, method: requestMethods[id] ?? "request \(id)")
|
|
470
|
+
} else if let result = object["result"] as? [String: Any] {
|
|
471
|
+
item.result = result
|
|
472
|
+
} else if object["result"] is NSNull {
|
|
473
|
+
// A successful JSON-RPC null result is an empty payload, not a
|
|
474
|
+
// transport failure. The Account view can then show its empty
|
|
475
|
+
// state while retaining the distinction from a missing reply.
|
|
476
|
+
item.result = [:]
|
|
477
|
+
} else {
|
|
478
|
+
item.error = RPCError.invalidResponse(requestMethods[id] ?? "request \(id)", "missing result or error")
|
|
479
|
+
}
|
|
480
|
+
item.semaphore.signal()
|
|
481
|
+
lock.unlock()
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private func receiveStderr(from handle: FileHandle) {
|
|
486
|
+
stderrReadLock.lock()
|
|
487
|
+
let data = handle.availableData
|
|
488
|
+
stderrReadLock.unlock()
|
|
489
|
+
guard !data.isEmpty else { return }
|
|
490
|
+
lock.lock()
|
|
491
|
+
stderrBuffer.append(data)
|
|
492
|
+
lock.unlock()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private func drainStderr() {
|
|
496
|
+
stderrReadLock.lock()
|
|
497
|
+
defer { stderrReadLock.unlock() }
|
|
498
|
+
while true {
|
|
499
|
+
let data = errorOutput.fileHandleForReading.availableData
|
|
500
|
+
guard !data.isEmpty else { return }
|
|
501
|
+
lock.lock()
|
|
502
|
+
stderrBuffer.append(data)
|
|
503
|
+
lock.unlock()
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private func rpcError(_ object: [String: Any], method: String) -> Error {
|
|
508
|
+
let code: String?
|
|
509
|
+
if let value = object["code"] as? NSNumber {
|
|
510
|
+
code = value.stringValue
|
|
511
|
+
} else {
|
|
512
|
+
code = object["code"] as? String
|
|
513
|
+
}
|
|
514
|
+
let message = (object["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
515
|
+
?? "unknown JSON-RPC error"
|
|
516
|
+
var data: String?
|
|
517
|
+
if let value = object["data"] as? String {
|
|
518
|
+
data = value
|
|
519
|
+
} else if let value = object["data"],
|
|
520
|
+
let encoded = try? JSONSerialization.data(withJSONObject: value),
|
|
521
|
+
let text = String(data: encoded, encoding: .utf8) {
|
|
522
|
+
data = text
|
|
523
|
+
}
|
|
524
|
+
return RPCError.server(method, code, message, data)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private func markTerminal(_ error: Error?) {
|
|
528
|
+
lock.lock()
|
|
529
|
+
guard terminalError == nil else {
|
|
530
|
+
lock.unlock()
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
let failure = error ?? RPCError.processExited(process.terminationStatus, stderrTextLocked())
|
|
534
|
+
terminalError = failure
|
|
535
|
+
let waiting = Array(pending.values)
|
|
536
|
+
for item in waiting { item.error = failure }
|
|
537
|
+
lock.unlock()
|
|
538
|
+
for item in waiting { item.semaphore.signal() }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
@discardableResult
|
|
542
|
+
private func stopAfterTimeout() -> Error {
|
|
543
|
+
lock.lock()
|
|
544
|
+
if terminalError == nil {
|
|
545
|
+
let detail = stderrTextLocked()
|
|
546
|
+
terminalError = detail.isEmpty
|
|
547
|
+
? RPCError.deadlineExceeded("app-server")
|
|
548
|
+
: RPCError.transport("app-server", "request timed out: \(detail)")
|
|
549
|
+
}
|
|
550
|
+
started = false
|
|
551
|
+
let waiting = Array(pending.values)
|
|
552
|
+
let failure = terminalError
|
|
553
|
+
for item in waiting { item.error = failure }
|
|
554
|
+
lock.unlock()
|
|
555
|
+
for item in waiting {
|
|
556
|
+
item.semaphore.signal()
|
|
557
|
+
}
|
|
558
|
+
input.fileHandleForWriting.closeFile()
|
|
559
|
+
if process.isRunning {
|
|
560
|
+
process.terminate()
|
|
561
|
+
_ = kill(process.processIdentifier, SIGKILL)
|
|
562
|
+
}
|
|
563
|
+
return failure ?? RPCError.deadlineExceeded("app-server")
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private func stderrTextLocked() -> String {
|
|
567
|
+
String(data: stderrBuffer, encoding: .utf8)?
|
|
568
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private func stderrText() -> String {
|
|
572
|
+
lock.lock()
|
|
573
|
+
let value = stderrTextLocked()
|
|
574
|
+
lock.unlock()
|
|
575
|
+
return value
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private static func configureProcess(_ process: Process) {
|
|
579
|
+
let environment = ProcessInfo.processInfo.environment
|
|
580
|
+
|
|
581
|
+
// Keep parity with the Python telemetry app-server adapter. This is
|
|
582
|
+
// especially useful for tests and non-standard Codex installations.
|
|
583
|
+
if let override = environment["CODEX_FLOW_APP_SERVER_COMMAND"]?
|
|
584
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
585
|
+
!override.isEmpty {
|
|
586
|
+
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
587
|
+
// Avoid a login shell here: startup files can print to stdout and
|
|
588
|
+
// corrupt the JSONL app-server stream before the first RPC reply.
|
|
589
|
+
process.arguments = ["-c", override]
|
|
590
|
+
process.environment = environment
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
595
|
+
func expandedPath(_ value: String) -> String {
|
|
596
|
+
(value as NSString).expandingTildeInPath
|
|
597
|
+
}
|
|
598
|
+
let codexHome = environment["CODEX_HOME"]
|
|
599
|
+
.map { URL(fileURLWithPath: expandedPath($0)) }
|
|
600
|
+
?? home.appendingPathComponent(".codex")
|
|
601
|
+
|
|
602
|
+
// Finder and Login Items normally inherit a minimal PATH. Honor any
|
|
603
|
+
// explicit PATH entries first, then probe installation locations. The
|
|
604
|
+
// nvm paths are enumerated rather than tied to one Node version, so a
|
|
605
|
+
// versioned upgrade keeps working after a relaunch from launchd.
|
|
606
|
+
var candidates: [URL] = []
|
|
607
|
+
var searchDirectories: [URL] = []
|
|
608
|
+
func appendDirectory(_ directory: URL) {
|
|
609
|
+
guard !searchDirectories.contains(where: { $0.path == directory.path }) else { return }
|
|
610
|
+
searchDirectories.append(directory)
|
|
611
|
+
}
|
|
612
|
+
func appendCandidate(_ candidate: URL) {
|
|
613
|
+
guard !candidates.contains(where: { $0.path == candidate.path }) else { return }
|
|
614
|
+
candidates.append(candidate)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if let explicit = environment["CODEX_FLOW_CODEX_PATH"]?
|
|
618
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
619
|
+
!explicit.isEmpty {
|
|
620
|
+
let explicitURL = URL(fileURLWithPath: expandedPath(explicit))
|
|
621
|
+
appendDirectory(explicitURL.deletingLastPathComponent())
|
|
622
|
+
appendCandidate(explicitURL)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if let path = environment["PATH"] {
|
|
626
|
+
for item in path.split(separator: ":") {
|
|
627
|
+
let directory = URL(fileURLWithPath: String(item))
|
|
628
|
+
appendDirectory(directory)
|
|
629
|
+
appendCandidate(directory.appendingPathComponent("codex"))
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let conventionalDirectories = [
|
|
634
|
+
home.appendingPathComponent(".local/bin"),
|
|
635
|
+
home.appendingPathComponent(".npm-global/bin"),
|
|
636
|
+
home.appendingPathComponent("Library/pnpm"),
|
|
637
|
+
home.appendingPathComponent(".volta/bin"),
|
|
638
|
+
home.appendingPathComponent(".bun/bin"),
|
|
639
|
+
codexHome.appendingPathComponent("bin"),
|
|
640
|
+
home.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources"),
|
|
641
|
+
URL(fileURLWithPath: "/opt/homebrew/bin"),
|
|
642
|
+
URL(fileURLWithPath: "/usr/local/bin"),
|
|
643
|
+
URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources"),
|
|
644
|
+
URL(fileURLWithPath: "/usr/bin"),
|
|
645
|
+
URL(fileURLWithPath: "/bin")
|
|
646
|
+
]
|
|
647
|
+
conventionalDirectories.forEach(appendDirectory)
|
|
648
|
+
conventionalDirectories.forEach { appendCandidate($0.appendingPathComponent("codex")) }
|
|
649
|
+
|
|
650
|
+
let nvmRoots = [
|
|
651
|
+
environment["NVM_DIR"].map { URL(fileURLWithPath: expandedPath($0)) },
|
|
652
|
+
home.appendingPathComponent(".nvm"),
|
|
653
|
+
home.appendingPathComponent(".config/nvm")
|
|
654
|
+
].compactMap { $0 }
|
|
655
|
+
for root in nvmRoots {
|
|
656
|
+
appendDirectory(root.appendingPathComponent("current/bin"))
|
|
657
|
+
appendDirectory(root.appendingPathComponent("default/bin"))
|
|
658
|
+
appendCandidate(root.appendingPathComponent("current/bin/codex"))
|
|
659
|
+
appendCandidate(root.appendingPathComponent("default/bin/codex"))
|
|
660
|
+
let versions = (try? FileManager.default.contentsOfDirectory(
|
|
661
|
+
at: root.appendingPathComponent("versions/node"),
|
|
662
|
+
includingPropertiesForKeys: [.isDirectoryKey],
|
|
663
|
+
options: [.skipsHiddenFiles]
|
|
664
|
+
))?.sorted { Self.nvmVersionComesFirst($0, $1) } ?? []
|
|
665
|
+
for version in versions {
|
|
666
|
+
let directory = version.appendingPathComponent("bin")
|
|
667
|
+
appendDirectory(directory)
|
|
668
|
+
appendCandidate(directory.appendingPathComponent("codex"))
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if let prefix = environment["npm_config_prefix"] ?? environment["PREFIX"] {
|
|
673
|
+
let directory = URL(fileURLWithPath: expandedPath(prefix)).appendingPathComponent("bin")
|
|
674
|
+
appendDirectory(directory)
|
|
675
|
+
appendCandidate(directory.appendingPathComponent("codex"))
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if let installedCodex = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) {
|
|
679
|
+
process.executableURL = installedCodex
|
|
680
|
+
process.arguments = ["app-server"]
|
|
681
|
+
var launchEnvironment = environment
|
|
682
|
+
// npm-installed Codex launchers commonly use ``#!/usr/bin/env
|
|
683
|
+
// node``. Include every discovered nvm/bin directory so the
|
|
684
|
+
// launcher and its Node runtime resolve under launchd as well.
|
|
685
|
+
let path = searchDirectories.map(\.path).joined(separator: ":")
|
|
686
|
+
if !path.isEmpty { launchEnvironment["PATH"] = path }
|
|
687
|
+
process.environment = launchEnvironment
|
|
688
|
+
} else {
|
|
689
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
690
|
+
process.arguments = ["codex", "app-server"]
|
|
691
|
+
var launchEnvironment = environment
|
|
692
|
+
let path = searchDirectories.map(\.path).joined(separator: ":")
|
|
693
|
+
if !path.isEmpty { launchEnvironment["PATH"] = path }
|
|
694
|
+
process.environment = launchEnvironment
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private static func nvmVersionComesFirst(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
699
|
+
let left = nvmVersionKey(lhs)
|
|
700
|
+
let right = nvmVersionKey(rhs)
|
|
701
|
+
for index in 0..<max(left.count, right.count) {
|
|
702
|
+
let leftPart = index < left.count ? left[index] : 0
|
|
703
|
+
let rightPart = index < right.count ? right[index] : 0
|
|
704
|
+
if leftPart != rightPart { return leftPart > rightPart }
|
|
705
|
+
}
|
|
706
|
+
return lhs.lastPathComponent > rhs.lastPathComponent
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private static func nvmVersionKey(_ url: URL) -> [Int] {
|
|
710
|
+
url.lastPathComponent
|
|
711
|
+
.split(whereSeparator: { !$0.isNumber })
|
|
712
|
+
.compactMap { Int($0) }
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
public enum AccountSnapshotService {
|
|
717
|
+
/// Keep the complete account flow bounded, including process startup,
|
|
718
|
+
/// writes, endpoint waits, EOF, and child cleanup.
|
|
719
|
+
public static let outerTimeout: TimeInterval = 10.0
|
|
720
|
+
|
|
721
|
+
private static let cacheLock = NSLock()
|
|
722
|
+
private static var cachedSnapshot: AccountSnapshot?
|
|
723
|
+
|
|
724
|
+
/// In-memory cached snapshot from the most recent successful load or manual update.
|
|
725
|
+
public static var cached: AccountSnapshot? {
|
|
726
|
+
cacheLock.lock()
|
|
727
|
+
defer { cacheLock.unlock() }
|
|
728
|
+
return cachedSnapshot
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/// Updates the in-memory cached snapshot.
|
|
732
|
+
public static func updateCache(_ snapshot: AccountSnapshot) {
|
|
733
|
+
cacheLock.lock()
|
|
734
|
+
cachedSnapshot = snapshot
|
|
735
|
+
cacheLock.unlock()
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/// Clears the in-memory cached snapshot.
|
|
739
|
+
public static func clearCache() {
|
|
740
|
+
cacheLock.lock()
|
|
741
|
+
cachedSnapshot = nil
|
|
742
|
+
cacheLock.unlock()
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
public static func load() throws -> AccountSnapshot {
|
|
746
|
+
let deadline = Date().addingTimeInterval(outerTimeout)
|
|
747
|
+
let rpc = try AccountAppServerRPC(deadline: deadline)
|
|
748
|
+
defer { rpc.close() }
|
|
749
|
+
|
|
750
|
+
try rpc.initialize()
|
|
751
|
+
|
|
752
|
+
var accountResponse: [String: Any]?
|
|
753
|
+
var limitsResponse: [String: Any]?
|
|
754
|
+
var failures: [String] = []
|
|
755
|
+
|
|
756
|
+
do {
|
|
757
|
+
accountResponse = try rpc.request(method: "account/read", params: ["refreshToken": false])
|
|
758
|
+
} catch {
|
|
759
|
+
accountResponse = nil
|
|
760
|
+
let message = error.localizedDescription
|
|
761
|
+
if !message.isEmpty && !failures.contains(message) { failures.append(message) }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
do {
|
|
765
|
+
limitsResponse = try rpc.request(method: "account/rateLimits/read", params: nil)
|
|
766
|
+
} catch {
|
|
767
|
+
limitsResponse = nil
|
|
768
|
+
let message = error.localizedDescription
|
|
769
|
+
if !message.isEmpty && !failures.contains(message) { failures.append(message) }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if !failures.isEmpty {
|
|
773
|
+
throw NSError(
|
|
774
|
+
domain: "FlowPilot.Account",
|
|
775
|
+
code: 2,
|
|
776
|
+
userInfo: [NSLocalizedDescriptionKey: failures.joined(separator: " ")]
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
guard accountResponse != nil || limitsResponse != nil else {
|
|
781
|
+
throw NSError(
|
|
782
|
+
domain: "FlowPilot.Account",
|
|
783
|
+
code: 3,
|
|
784
|
+
userInfo: [NSLocalizedDescriptionKey: L("Account data is unavailable from Codex.", "Codex 暂未返回账户数据。")]
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
let snapshot = try parse(accountResponse: accountResponse, limitsResponse: limitsResponse, now: Date())
|
|
788
|
+
updateCache(snapshot)
|
|
789
|
+
return snapshot
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/// Pure response parsing is public so fixture tests can exercise API
|
|
793
|
+
/// compatibility without launching a real Codex process.
|
|
794
|
+
public static func parse(
|
|
795
|
+
accountResponse: [String: Any]?,
|
|
796
|
+
limitsResponse: [String: Any]?,
|
|
797
|
+
now: Date = Date(),
|
|
798
|
+
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
799
|
+
) throws -> AccountSnapshot {
|
|
800
|
+
guard accountResponse != nil || limitsResponse != nil else {
|
|
801
|
+
throw NSError(
|
|
802
|
+
domain: "FlowPilot.Account",
|
|
803
|
+
code: 4,
|
|
804
|
+
userInfo: [NSLocalizedDescriptionKey: L("Account data is unavailable from Codex.", "Codex 暂未返回账户数据。")]
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let account = accountResponse?["account"] as? [String: Any]
|
|
809
|
+
let requiresAuth = boolValue(
|
|
810
|
+
accountResponse?["requiresOpenAIAuth"] ?? accountResponse?["requiresOpenaiAuth"]
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
let legacy = limitsResponse?["rateLimits"] as? [String: Any]
|
|
814
|
+
let byId = limitsResponse?["rateLimitsByLimitId"] as? [String: Any]
|
|
815
|
+
let codex = byId?["codex"] as? [String: Any]
|
|
816
|
+
let windows = parseQuotaWindows(codex: codex, legacy: legacy)
|
|
817
|
+
|
|
818
|
+
// Codex is authoritative when a field exists, while legacy values fill
|
|
819
|
+
// holes in an incomplete multi-bucket response.
|
|
820
|
+
let mergedSnapshot = mergedDictionary(primary: codex, fallback: legacy)
|
|
821
|
+
let resetSummary = limitsResponse?["rateLimitResetCredits"] as? [String: Any]
|
|
822
|
+
let resetCount = intValue(resetSummary?["availableCount"])
|
|
823
|
+
let resetCredits: [AccountResetCredit]
|
|
824
|
+
if let rawCredits = resetSummary?["credits"] as? [[String: Any]] {
|
|
825
|
+
resetCredits = rawCredits.compactMap { raw -> AccountResetCredit? in
|
|
826
|
+
guard let id = stringValue(raw["id"]), !id.isEmpty else { return nil }
|
|
827
|
+
return AccountResetCredit(
|
|
828
|
+
id: id,
|
|
829
|
+
status: stringValue(raw["status"]),
|
|
830
|
+
expiresAt: dateValue(raw["expiresAt"]),
|
|
831
|
+
title: stringValue(raw["title"])
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
} else {
|
|
835
|
+
// Do not synthesize credits from availableCount. An omitted credits
|
|
836
|
+
// list is different from an explicitly empty list to API clients;
|
|
837
|
+
// this model keeps the list empty while preserving the authoritative
|
|
838
|
+
// nullable count above.
|
|
839
|
+
resetCredits = []
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let credits = mergedSnapshot?["credits"] as? [String: Any]
|
|
843
|
+
let plan = stringValue(account?["planType"]) ?? stringValue(mergedSnapshot?["planType"])
|
|
844
|
+
let rpcAccountId = nonBlankStringValue(account?["id"])
|
|
845
|
+
?? nonBlankStringValue(account?["accountId"])
|
|
846
|
+
?? nonBlankStringValue(account?["account_id"])
|
|
847
|
+
// auth.json is a local identity source, but it is only meaningful when
|
|
848
|
+
// account/read confirms a real account. A limits-only or empty response
|
|
849
|
+
// must remain empty and must not inherit an unrelated local identity.
|
|
850
|
+
let resolvedAccountId = rpcAccountId
|
|
851
|
+
?? (isChatGPTAccount(account) ? resolveCodexAuthAccountId(environment: environment) : nil)
|
|
852
|
+
return AccountSnapshot(
|
|
853
|
+
accountId: resolvedAccountId,
|
|
854
|
+
accountType: stringValue(account?["type"]),
|
|
855
|
+
email: stringValue(account?["email"]),
|
|
856
|
+
planType: plan,
|
|
857
|
+
requiresOpenAIAuth: requiresAuth,
|
|
858
|
+
quotaWindows: windows,
|
|
859
|
+
resetCreditCount: resetCount,
|
|
860
|
+
resetCredits: resetCredits,
|
|
861
|
+
hasCredits: boolValue(credits?["hasCredits"]),
|
|
862
|
+
unlimitedCredits: boolValue(credits?["unlimited"]),
|
|
863
|
+
creditsBalance: stringValue(credits?["balance"]),
|
|
864
|
+
spendControlReached: boolValue(mergedSnapshot?["spendControlReached"]),
|
|
865
|
+
rateLimitReachedType: stringValue(mergedSnapshot?["rateLimitReachedType"]),
|
|
866
|
+
fetchedAt: now
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
public static func resolveCodexAuthAccountId(
|
|
871
|
+
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
872
|
+
) -> String? {
|
|
873
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
874
|
+
let codexHome: URL
|
|
875
|
+
if let configured = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
876
|
+
!configured.isEmpty {
|
|
877
|
+
codexHome = URL(fileURLWithPath: (configured as NSString).expandingTildeInPath)
|
|
878
|
+
} else {
|
|
879
|
+
codexHome = home.appendingPathComponent(".codex")
|
|
880
|
+
}
|
|
881
|
+
let authURL = codexHome.appendingPathComponent("auth.json")
|
|
882
|
+
guard let data = try? Data(contentsOf: authURL),
|
|
883
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
884
|
+
return nil
|
|
885
|
+
}
|
|
886
|
+
if let tokens = json["tokens"] as? [String: Any],
|
|
887
|
+
let accId = tokens["account_id"] as? String, !accId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
888
|
+
return accId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
889
|
+
}
|
|
890
|
+
if let accId = json["account_id"] as? String, !accId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
891
|
+
return accId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
892
|
+
}
|
|
893
|
+
return nil
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private static func isChatGPTAccount(_ account: [String: Any]?) -> Bool {
|
|
897
|
+
// API-key or unknown account types cannot borrow a local ChatGPT identity.
|
|
898
|
+
nonBlankStringValue(account?["type"]) == "chatgpt"
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private struct QuotaCandidate {
|
|
902
|
+
let slot: String
|
|
903
|
+
let sourceRank: Int
|
|
904
|
+
let duration: Int?
|
|
905
|
+
let used: Double?
|
|
906
|
+
let reset: Date?
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private static func parseQuotaWindows(
|
|
910
|
+
codex: [String: Any]?,
|
|
911
|
+
legacy: [String: Any]?
|
|
912
|
+
) -> [AccountQuotaWindow] {
|
|
913
|
+
var candidates: [QuotaCandidate] = []
|
|
914
|
+
for slot in ["primary", "secondary"] {
|
|
915
|
+
let codexRaw = codex?[slot] as? [String: Any]
|
|
916
|
+
let legacyRaw = legacy?[slot] as? [String: Any]
|
|
917
|
+
guard let merged = mergedDictionary(primary: codexRaw, fallback: legacyRaw), !merged.isEmpty else {
|
|
918
|
+
continue
|
|
919
|
+
}
|
|
920
|
+
let duration = firstIntValue(in: codexRaw, fallback: legacyRaw, keys: ["windowDurationMins", "windowMinutes", "window_duration_mins"])
|
|
921
|
+
let used = firstDoubleValue(in: codexRaw, fallback: legacyRaw, keys: ["usedPercent", "used_percent"])
|
|
922
|
+
let reset = firstDateValue(in: codexRaw, fallback: legacyRaw, keys: ["resetsAt", "resets_at"])
|
|
923
|
+
guard duration != nil || used != nil || reset != nil else { continue }
|
|
924
|
+
candidates.append(QuotaCandidate(
|
|
925
|
+
slot: slot,
|
|
926
|
+
sourceRank: codexRaw == nil ? 1 : 0,
|
|
927
|
+
duration: duration,
|
|
928
|
+
used: used,
|
|
929
|
+
reset: reset
|
|
930
|
+
))
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// A duplicate response can expose the same logical window as both
|
|
934
|
+
// primary and secondary. Prefer codex over legacy and primary over
|
|
935
|
+
// secondary, while retaining same-reset windows when durations differ.
|
|
936
|
+
var selected: [String: QuotaCandidate] = [:]
|
|
937
|
+
for candidate in candidates {
|
|
938
|
+
let key = logicalWindowKey(candidate)
|
|
939
|
+
guard let existing = selected[key] else {
|
|
940
|
+
selected[key] = candidate
|
|
941
|
+
continue
|
|
942
|
+
}
|
|
943
|
+
let candidateRank = (candidate.sourceRank, candidate.slot == "primary" ? 0 : 1)
|
|
944
|
+
let existingRank = (existing.sourceRank, existing.slot == "primary" ? 0 : 1)
|
|
945
|
+
if candidateRank < existingRank {
|
|
946
|
+
selected[key] = candidate
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return selected.values.map { candidate in
|
|
951
|
+
AccountQuotaWindow(
|
|
952
|
+
id: stableWindowID(candidate),
|
|
953
|
+
slot: candidate.slot,
|
|
954
|
+
durationMinutes: candidate.duration,
|
|
955
|
+
usedPercent: candidate.used,
|
|
956
|
+
resetsAt: candidate.reset
|
|
957
|
+
)
|
|
958
|
+
}.sorted {
|
|
959
|
+
let leftDuration = $0.durationMinutes ?? Int.max
|
|
960
|
+
let rightDuration = $1.durationMinutes ?? Int.max
|
|
961
|
+
if leftDuration != rightDuration { return leftDuration < rightDuration }
|
|
962
|
+
return $0.id < $1.id
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private static func logicalWindowKey(_ candidate: QuotaCandidate) -> String {
|
|
967
|
+
let duration = candidate.duration.map(String.init) ?? "unknown"
|
|
968
|
+
if let reset = candidate.reset {
|
|
969
|
+
let millis = Int64((reset.timeIntervalSince1970 * 1000.0).rounded())
|
|
970
|
+
return "duration=\(duration)|reset=\(millis)"
|
|
971
|
+
}
|
|
972
|
+
// When reset is missing, keep distinct unknown slots apart unless they
|
|
973
|
+
// have the same duration and slot identity. This avoids collapsing all
|
|
974
|
+
// malformed/partial windows into one row.
|
|
975
|
+
return "duration=\(duration)|reset=none|slot=\(candidate.slot)"
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private static func stableWindowID(_ candidate: QuotaCandidate) -> String {
|
|
979
|
+
let duration = candidate.duration.map(String.init) ?? "unknown"
|
|
980
|
+
if let reset = candidate.reset {
|
|
981
|
+
let millis = Int64((reset.timeIntervalSince1970 * 1000.0).rounded())
|
|
982
|
+
return "quota-\(duration)-\(millis)"
|
|
983
|
+
}
|
|
984
|
+
return "quota-\(duration)-\(candidate.slot)"
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private static func mergedDictionary(
|
|
988
|
+
primary: [String: Any]?,
|
|
989
|
+
fallback: [String: Any]?
|
|
990
|
+
) -> [String: Any]? {
|
|
991
|
+
guard primary != nil || fallback != nil else { return nil }
|
|
992
|
+
var result = fallback ?? [:]
|
|
993
|
+
for (key, value) in primary ?? [:] where !(value is NSNull) {
|
|
994
|
+
result[key] = value
|
|
995
|
+
}
|
|
996
|
+
return result
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private static func firstIntValue(
|
|
1000
|
+
in primary: [String: Any]?,
|
|
1001
|
+
fallback: [String: Any]?,
|
|
1002
|
+
keys: [String]
|
|
1003
|
+
) -> Int? {
|
|
1004
|
+
for key in keys {
|
|
1005
|
+
if let value = primary?[key], let parsed = intValue(value) { return parsed }
|
|
1006
|
+
}
|
|
1007
|
+
for key in keys {
|
|
1008
|
+
if let value = fallback?[key], let parsed = intValue(value) { return parsed }
|
|
1009
|
+
}
|
|
1010
|
+
return nil
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private static func firstDoubleValue(
|
|
1014
|
+
in primary: [String: Any]?,
|
|
1015
|
+
fallback: [String: Any]?,
|
|
1016
|
+
keys: [String]
|
|
1017
|
+
) -> Double? {
|
|
1018
|
+
for key in keys {
|
|
1019
|
+
if let value = primary?[key], let parsed = doubleValue(value) { return parsed }
|
|
1020
|
+
}
|
|
1021
|
+
for key in keys {
|
|
1022
|
+
if let value = fallback?[key], let parsed = doubleValue(value) { return parsed }
|
|
1023
|
+
}
|
|
1024
|
+
return nil
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private static func firstDateValue(
|
|
1028
|
+
in primary: [String: Any]?,
|
|
1029
|
+
fallback: [String: Any]?,
|
|
1030
|
+
keys: [String]
|
|
1031
|
+
) -> Date? {
|
|
1032
|
+
for key in keys {
|
|
1033
|
+
if let value = primary?[key], let parsed = dateValue(value) { return parsed }
|
|
1034
|
+
}
|
|
1035
|
+
for key in keys {
|
|
1036
|
+
if let value = fallback?[key], let parsed = dateValue(value) { return parsed }
|
|
1037
|
+
}
|
|
1038
|
+
return nil
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private static func isBooleanNumber(_ number: NSNumber) -> Bool {
|
|
1042
|
+
#if canImport(Darwin)
|
|
1043
|
+
return CFGetTypeID(number) == CFBooleanGetTypeID()
|
|
1044
|
+
#else
|
|
1045
|
+
return String(cString: number.objCType) == "c"
|
|
1046
|
+
#endif
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private static func stringValue(_ value: Any?) -> String? {
|
|
1050
|
+
if let value = value as? String { return value }
|
|
1051
|
+
if let value = value as? NSNumber { return value.stringValue }
|
|
1052
|
+
return nil
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private static func nonBlankStringValue(_ value: Any?) -> String? {
|
|
1056
|
+
guard let value = stringValue(value)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
1057
|
+
!value.isEmpty else { return nil }
|
|
1058
|
+
return value
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private static func intValue(_ value: Any?) -> Int? {
|
|
1062
|
+
if let number = value as? NSNumber, !isBooleanNumber(number) { return number.intValue }
|
|
1063
|
+
if let value = value as? String { return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
1064
|
+
return nil
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private static func doubleValue(_ value: Any?) -> Double? {
|
|
1068
|
+
if let number = value as? NSNumber, !isBooleanNumber(number) { return number.doubleValue }
|
|
1069
|
+
if let value = value as? String { return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
1070
|
+
return nil
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
private static func boolValue(_ value: Any?) -> Bool? {
|
|
1074
|
+
if let number = value as? NSNumber {
|
|
1075
|
+
if isBooleanNumber(number) { return number.boolValue }
|
|
1076
|
+
return nil
|
|
1077
|
+
}
|
|
1078
|
+
if let value = value as? String {
|
|
1079
|
+
switch value.lowercased() {
|
|
1080
|
+
case "true", "1", "yes": return true
|
|
1081
|
+
case "false", "0", "no": return false
|
|
1082
|
+
default: return nil
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return nil
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
private static func dateValue(_ value: Any?) -> Date? {
|
|
1089
|
+
if let raw = doubleValue(value), raw > 0 {
|
|
1090
|
+
return Date(timeIntervalSince1970: raw > 1_000_000_000_000 ? raw / 1000.0 : raw)
|
|
1091
|
+
}
|
|
1092
|
+
if let value = value as? String {
|
|
1093
|
+
return ISO8601DateFormatter().date(from: value)
|
|
1094
|
+
}
|
|
1095
|
+
return nil
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
public func formatAccountDate(_ date: Date) -> String {
|
|
1100
|
+
formatLocalDateTime(date)
|
|
1101
|
+
}
|