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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. 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
+ }