Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
3387cc8
chore: start 0.32.5 development
steipete Jun 2, 2026
65e39f4
Improve merged menu dismiss latency (#1286)
hhh2210 Jun 3, 2026
de55f48
Reduce merged icon observation churn (#1297)
hhh2210 Jun 5, 2026
b2d5129
fix: skip unchanged quota indicator constraints
steipete Jun 5, 2026
7c083fa
Cache menu card heights by content
hhh2210 Jun 5, 2026
10239cc
Harden menu height fingerprints
hhh2210 Jun 5, 2026
c7f8336
Trim menu card rebuild hot paths
hhh2210 Jun 5, 2026
989a757
Fold system text scale into menu card height cache key
hhh2210 Jun 5, 2026
ef17fbd
Skip closed merged-menu rebuild until next open
hhh2210 Jun 5, 2026
400f98a
docs: reference menu height cache PR
steipete Jun 5, 2026
88eb603
Merge pull request #1314 from hhh2210/codex/menu-card-height-fingerprint
steipete Jun 5, 2026
af2c754
Merge pull request #1319 from steipete/fix/quota-indicator-constraint…
steipete Jun 5, 2026
8c043a4
Fix Alibaba token plan SEC token fallback
YanxinXue Jun 1, 2026
e652f74
Preserve Alibaba credential error mapping
YanxinXue Jun 1, 2026
cb05f9c
Merge pull request #1257 from YanxinXue/codex/fix-ali-token-plan-update
steipete Jun 5, 2026
fa9b757
fix: retry login shell path capture
steipete Jun 5, 2026
acc0fc7
Merge pull request #1318 from steipete/fix/login-shell-path-cache-retry
steipete Jun 5, 2026
9036820
Fix MiniMax token plan usage display
XWind18 Jun 1, 2026
3298e47
Harden MiniMax metadata fetch
XWind18 Jun 2, 2026
65a41cb
Handle MiniMax token plan quotas
XWind18 Jun 2, 2026
bfff3df
Preserve MiniMax API token fallback
XWind18 Jun 3, 2026
df74854
Surface constrained MiniMax menu metric
XWind18 Jun 4, 2026
e4ea2bf
Stabilize rapid switcher rebuild test
XWind18 Jun 4, 2026
20d2ebe
Fix MiniMax boosted quota percent
XWind18 Jun 4, 2026
bd0bbc0
fix: Preserve subscription metadata in snapshot copies
steipete Jun 5, 2026
7b2487b
fix: Preserve MiniMax API fallback on parse errors
steipete Jun 5, 2026
fb5dcf8
fix: Preserve MiniMax web auth errors
steipete Jun 5, 2026
dfb9912
fix: Format MiniMax subscription dates in provider timezone
steipete Jun 5, 2026
d6e225a
fix: Preserve MiniMax token-plan fallbacks
steipete Jun 5, 2026
26a53f2
fix: Guard MiniMax metadata host overrides
steipete Jun 5, 2026
09afa2a
fix: Order MiniMax menu quota rows
steipete Jun 5, 2026
b7d36fb
test: update MiniMax china fallback expectation
steipete Jun 5, 2026
867ac58
Merge pull request #1266 from XWind18/codex/minimax-token-plan
steipete Jun 5, 2026
d3151b7
Add native French localization support
Yuxin-Qiao Jun 6, 2026
ae63897
Add native Ukrainian localization support
Yuxin-Qiao Jun 6, 2026
702af5a
Fix Claude selected metric reserve display
steipete Jun 7, 2026
88b00e5
Memoize models.dev catalog load outcomes
turbothad Jun 7, 2026
0da296f
fix: respect constrained Antigravity summary lane
steipete Jun 7, 2026
610bdbc
fix: show Codex Spark pace details
steipete Jun 7, 2026
9182428
fix: show Cursor billing-cycle pace details
steipete Jun 7, 2026
9a2d780
fix: time out stalled managed Codex login
steipete Jun 7, 2026
99704e0
docs(cli): document serve request timeout
enieuwy Jun 7, 2026
4da1e51
fix: clean up Claude probe session artifacts
steipete Jun 7, 2026
1b29f55
docs: add showy-quota integration
enieuwy Jun 7, 2026
7cd8690
fix: prevent z.ai overview submenu recursion
RajvardhanPatil07 Jun 7, 2026
574c158
docs: add z.ai submenu changelog entry
steipete Jun 7, 2026
fa8d8f6
fix: backfill Codex account reset windows
steipete Jun 7, 2026
1ea4e83
fix: harden Codex reset backfill ownership
steipete Jun 7, 2026
870e639
fix: harden Codex reset backfill ownership
steipete Jun 7, 2026
52d09da
fix: guard Codex reset cache by auth fingerprint
steipete Jun 7, 2026
0b7de58
fix: detect Antigravity CLI usage
oyaah Jun 7, 2026
4652e40
fix: avoid closed menu rebuilds during data refresh
Nicolas0315 Jun 7, 2026
1583d6c
feat: add native Dutch localization support
Yuxin-Qiao Jun 7, 2026
b442183
feat: add native Vietnamese localization support
Yuxin-Qiao Jun 7, 2026
fca42e9
fix: scope Codex reset cache to auth identity
steipete Jun 7, 2026
0401a5c
fix: tolerate Codex token refresh fingerprint churn
steipete Jun 7, 2026
e086ffb
fix: keep email-only Codex auth switches isolated
steipete Jun 7, 2026
fff91e2
test: move Codex auth fingerprint apply coverage
steipete Jun 7, 2026
e0a9937
fix: guard stacked Codex account refresh apply
steipete Jun 7, 2026
1a1ef6d
test: isolate Codex account info coverage
steipete Jun 7, 2026
3dcb728
fix: harden Codex refresh guard cache invalidation
steipete Jun 7, 2026
ccca33b
fix: discard stale Codex auth failures
steipete Jun 7, 2026
0cc9d7c
fix: separate Codex refresh keys by auth fingerprint
steipete Jun 7, 2026
37d7721
fix: allow Codex usage success after auth material appears
steipete Jun 7, 2026
6b95888
fix: refresh Codex dashboard authority cache
steipete Jun 7, 2026
9593846
fix: read managed Codex auth fingerprint from home
steipete Jun 7, 2026
d8bec31
fix: preserve Codex reset cache across token rotation
steipete Jun 7, 2026
bf795ec
fix: refresh managed Codex visible auth guards
steipete Jun 7, 2026
6039cb2
fix: reject stale managed Codex email results
steipete Jun 7, 2026
a154c98
fix: refresh migrated Codex visible account identity
steipete Jun 7, 2026
1a2a787
fix: apply Codex dashboard policy cleanup after token rotation
steipete Jun 7, 2026
046fb79
fix: reject stale Codex results after auth removal
steipete Jun 7, 2026
eeda76e
fix: refresh Codex stacked account gate
steipete Jun 7, 2026
f926ee3
fix: require live Codex auth for visible refresh matches
steipete Jun 7, 2026
9b54760
fix: apply Codex dashboard policy cleanup after email rotation
steipete Jun 7, 2026
034300c
fix: hydrate Codex account snapshots with live auth
steipete Jun 7, 2026
ca5ebd8
test: stabilize login shell cache retry test
steipete Jun 7, 2026
db18443
Merge pull request #1349 from steipete/fix/codex-account-reset-backfill
steipete Jun 7, 2026
f351a7d
perf: cut menu readiness signature cost on store changes
Yuxin-Qiao Jun 7, 2026
b7744f2
perf: size hosted menu charts without a throwaway hosting controller
Yuxin-Qiao Jun 7, 2026
8da0cfc
fix: resync menu readiness baseline on root menu open
Yuxin-Qiao Jun 7, 2026
7f4bac6
Defer status-menu quit during shutdown [skip ci]
jskoiz Jun 7, 2026
deb5efd
Cache localized bundle resolution to cut main-thread disk lookups (#1…
Yuxin-Qiao Jun 8, 2026
e7915bd
Fix localization cache test to use an existing lproj catalog
Yuxin-Qiao Jun 8, 2026
37c8e8c
Gate readiness baseline resync on fresh visible menu content (#1351)
Yuxin-Qiao Jun 8, 2026
463ec91
fix: speed up merged provider switching
steipete Jun 8, 2026
1818638
Merge pull request #1355 from Yuxin-Qiao/perf/cache-localized-bundle
steipete Jun 8, 2026
c81a9f2
Merge pull request #1352 from Yuxin-Qiao/perf/hosted-chart-measure
steipete Jun 8, 2026
7e73b41
fix: handle pending menu readiness observer
steipete Jun 8, 2026
c87bc55
fix: invalidate sibling menus for readiness observer
steipete Jun 8, 2026
edc4f15
fix: preserve in-flight menu refresh handling
steipete Jun 8, 2026
6b5191e
fix: keep menu observation invalidation after readiness consume
steipete Jun 8, 2026
2bcd223
fix: track menu readiness baseline version
steipete Jun 8, 2026
534d8e8
fix: advance readiness baseline version on equal signature
steipete Jun 8, 2026
16287c0
fix: track rendered menu readiness signatures
steipete Jun 8, 2026
1b0b1d6
fix: repair equal-signature menu readiness reopen
steipete Jun 8, 2026
322c8f8
Merge pull request #1351 from Yuxin-Qiao/perf/menu-readiness-signature
steipete Jun 8, 2026
7b6b02d
Merge pull request #1354 from jskoiz/fix/defer-status-menu-quit
steipete Jun 8, 2026
8448b81
docs: update changelog for menu performance fixes
steipete Jun 8, 2026
100c8d3
style: format status controller declarations
steipete Jun 8, 2026
e410f9e
Merge upstream CodexBar main
ColumbusLabs Jun 8, 2026
f975a0a
Track reviewed upstream monitor base
ColumbusLabs Jun 8, 2026
9778128
perf: eliminate merged menu switching hangs
steipete Jun 9, 2026
d7b58a0
docs: finalize 0.32.5 changelog
steipete Jun 9, 2026
ab1d52f
Merge latest upstream CodexBar main
ColumbusLabs Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions .github/workflows/upstream-monitor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,24 @@ jobs:
UPSTREAM_BRANCH=$(remote_default_branch upstream)
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env)
UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}"
if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then
echo "Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-<unset>} from version.env" >&2
UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env)
if [ -n "$UPSTREAM_MONITOR_BASE" ]; then
UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE"
UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE"
else
UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}"
UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION"
fi
if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then
echo "Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-<unset>} from version.env" >&2
exit 1
fi
echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT
echo "upstream_version=$UPSTREAM_VERSION" >> $GITHUB_OUTPUT
echo "upstream_base_ref=$UPSTREAM_BASE_REF" >> $GITHUB_OUTPUT
echo "upstream_base_label=$UPSTREAM_BASE_LABEL" >> $GITHUB_OUTPUT

# Count new commits since the upstream version last recorded in version.env.
# Count new commits since the upstream ref last reviewed/merged by QuotaKit.
UPSTREAM_NEW=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT

Expand All @@ -105,12 +113,14 @@ jobs:
const upstreamRef = '${{ steps.check.outputs.upstream_ref }}';
const upstreamVersion = '${{ steps.check.outputs.upstream_version }}';
const upstreamBaseRef = '${{ steps.check.outputs.upstream_base_ref }}';
const upstreamBaseLabel = '${{ steps.check.outputs.upstream_base_label }}';
const upstreamBranch = upstreamRef.replace('upstream/', '');
const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`;

const body = `## Upstream Changes Detected

**steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamVersion}\`
**steipete/CodexBar:** ${upstreamCommits} new commits since \`${upstreamBaseLabel}\`
**Last shipped upstream version:** \`${upstreamVersion}\`
**Source refs:** ${upstreamBaseRef}..${upstreamRef}

### steipete/CodexBar Recent Commits
Expand All @@ -131,7 +141,7 @@ jobs:
\`\`\`

### Links
- [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamVersion}...steipete:CodexBar:${upstreamBranch})
- [steipete commits](https://github.com/steipete/CodexBar/compare/${upstreamBaseLabel}...steipete:CodexBar:${upstreamBranch})

---
*Auto-generated by upstream-monitor workflow*
Expand Down Expand Up @@ -159,7 +169,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issues.data[0].number,
body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamVersion})`
body: `Updated with latest steipete/CodexBar changes (${upstreamCommits} upstream commits since ${upstreamBaseLabel})`
});
} else {
// Create new issue
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ xcodebuild -project CodexBarMobile/CodexBarMobile.xcodeproj \
CODE_SIGNING_ALLOWED=NO build
```

Never run checks that can display macOS Keychain prompts unless the user explicitly asks for live provider validation. Prefer parser tests, stubs, test stores, or no-UI keychain queries.
Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts unless the user explicitly asks for live provider validation; use parser tests, stubs, test stores, or `KeychainNoUIQuery`.

## Release Configuration

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Notable QuotaKit Mac and cross-platform release changes are documented here.
Older upstream history is intentionally preserved in Git, but this file now focuses
on Columbus Labs QuotaKit releases and product-facing changes.

## Upcoming

### Changed

- Synced trusted upstream CodexBar Mac improvements after `v0.32.4`, including
Codex account/auth hardening, MiniMax quota fixes, menu performance updates,
merged provider-switching hang fixes, Claude probe cleanup,
Antigravity/Alibaba/Cursor fixes, and additional Mac localizations.

## 0.32.4.4 / iOS 1.11.1 — 2026-06-08

### Fixed
Expand Down
23 changes: 16 additions & 7 deletions Scripts/check_upstreams.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,31 +67,40 @@ echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}"
UPSTREAM_BRANCH=$(remote_default_branch upstream)
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
UPSTREAM_VERSION=$(awk -F= '$1 == "UPSTREAM_VERSION" {print $2; exit}' version.env)
UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}"
if [ -z "$UPSTREAM_VERSION" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then
echo -e "${RED}Error: Could not resolve UPSTREAM_VERSION=${UPSTREAM_VERSION:-<unset>} from version.env.${NC}" >&2
UPSTREAM_MONITOR_BASE=$(awk -F= '$1 == "UPSTREAM_MONITOR_BASE" {print $2; exit}' version.env)
if [ -n "$UPSTREAM_MONITOR_BASE" ]; then
UPSTREAM_BASE_REF="$UPSTREAM_MONITOR_BASE"
UPSTREAM_BASE_LABEL="$UPSTREAM_MONITOR_BASE"
else
UPSTREAM_BASE_REF="upstream/${UPSTREAM_VERSION}"
UPSTREAM_BASE_LABEL="$UPSTREAM_VERSION"
fi
if [ -z "$UPSTREAM_BASE_REF" ] || ! git rev-parse --verify -q "$UPSTREAM_BASE_REF" >/dev/null; then
echo -e "${RED}Error: Could not resolve upstream monitor base ${UPSTREAM_BASE_REF:-<unset>} from version.env.${NC}" >&2
exit 1
fi

UPSTREAM_COUNT=$(git log --oneline "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')

if [ "$UPSTREAM_COUNT" -gt 0 ]; then
echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_VERSION${NC}"
echo -e "${GREEN}Found $UPSTREAM_COUNT new commits since $UPSTREAM_BASE_LABEL${NC}"
echo "Last shipped upstream version: $UPSTREAM_VERSION"
echo ""
git log --oneline --graph "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" --no-merges | head -20 || true
echo ""
echo -e "${YELLOW}Files changed:${NC}"
git diff --stat "${UPSTREAM_BASE_REF}..${UPSTREAM_REF}" | tail -20 || true
else
echo -e "${GREEN}No new commits since $UPSTREAM_VERSION${NC}"
echo -e "${GREEN}No new commits since $UPSTREAM_BASE_LABEL${NC}"
echo "Last shipped upstream version: $UPSTREAM_VERSION"
fi
echo ""

# Summary
echo -e "${BLUE}==> Summary${NC}"
echo "Upstream commits since $UPSTREAM_VERSION: $UPSTREAM_COUNT"
echo "Upstream commits since $UPSTREAM_BASE_LABEL: $UPSTREAM_COUNT"

echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo " Review upstream: ./Scripts/review_upstream.sh upstream"
echo " Detailed diff: git diff upstream/$UPSTREAM_VERSION..upstream/$UPSTREAM_BRANCH"
echo " Detailed diff: git diff $UPSTREAM_BASE_REF..upstream/$UPSTREAM_BRANCH"
59 changes: 59 additions & 0 deletions Sources/CodexBar/CodexAccountUsageSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,53 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un

private struct Record: Codable {
let id: String
let accountIdentity: AccountIdentity?
let snapshot: UsageSnapshot?
let error: String?
let sourceLabel: String?
}

private struct AccountIdentity: Codable, Equatable {
let normalizedEmail: String?
let workspaceAccountID: String?
let authFingerprint: String?
let storedAccountID: UUID?
let selectionSource: CodexActiveSource?

init(account: CodexVisibleAccount) {
self.normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email)
self.workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
account.workspaceAccountID)
self.authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint)
self.storedAccountID = account.storedAccountID
self.selectionSource = account.selectionSource
}

func matches(_ account: CodexVisibleAccount) -> Bool {
guard self.normalizedEmail == CodexIdentityResolver.normalizeEmail(account.email) else {
return false
}

let currentWorkspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(
account.workspaceAccountID)
if self.workspaceAccountID != nil || currentWorkspaceAccountID != nil {
return self.workspaceAccountID == currentWorkspaceAccountID
}

let currentAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint)
if self.authFingerprint != nil || currentAuthFingerprint != nil {
return self.authFingerprint == currentAuthFingerprint
}

if self.storedAccountID != nil || account.storedAccountID != nil {
return self.storedAccountID == account.storedAccountID
}

guard let selectionSource else { return true }
return selectionSource == account.selectionSource
}
}

private static let currentVersion = 1

private let fileURL: URL
Expand All @@ -41,6 +83,11 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un
let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) })
return payload.records.compactMap { record in
guard let account = accountsByID[record.id] else { return nil }
guard record.accountIdentity?.matches(account)
?? Self.canHydrateLegacyRecord(record, account: account)
else {
return nil
}
return CodexAccountUsageSnapshot(
account: account,
snapshot: record.snapshot,
Expand All @@ -55,6 +102,7 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un
records: snapshots.map { snapshot in
Record(
id: snapshot.id,
accountIdentity: AccountIdentity(account: snapshot.account),
snapshot: snapshot.snapshot,
error: snapshot.error,
sourceLabel: snapshot.sourceLabel)
Expand All @@ -77,6 +125,17 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un
}
}

private static func canHydrateLegacyRecord(_ record: Record, account: CodexVisibleAccount) -> Bool {
guard record.accountIdentity == nil else { return false }
let normalizedID = CodexIdentityResolver.normalizeEmail(record.id)
let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email)
let isEmailOnlyVisibleID = normalizedID == normalizedEmail
guard isEmailOnlyVisibleID else { return true }
return CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) == nil &&
account.storedAccountID == nil &&
CodexAuthFingerprint.normalize(account.authFingerprint) == nil
}

static func defaultURL() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser
Expand Down
81 changes: 64 additions & 17 deletions Sources/CodexBar/CodexLoginRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@ struct CodexLoginRunner {
let output: String
}

static func run(homePath: String? = nil, timeout: TimeInterval = 120) async -> Result {
static func run(
homePath: String? = nil,
timeout: TimeInterval = 120,
environment: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current) async -> Result
{
await Task(priority: .userInitiated) {
var env = ProcessInfo.processInfo.environment
var env = environment
env["PATH"] = PathBuilder.effectivePATH(
purposes: [.rpc, .tty, .nodeTooling],
env: env,
loginPATH: LoginShellPathCache.shared.current)
loginPATH: loginPATH)
env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath)

guard let executable = BinaryLocator.resolveCodexBinary(
env: env,
loginPATH: LoginShellPathCache.shared.current)
loginPATH: loginPATH)
else {
return Result(outcome: .missingBinary, output: "")
}
Expand All @@ -42,6 +47,11 @@ struct CodexLoginRunner {
process.standardOutput = stdout
process.standardError = stderr

let termination = ProcessTermination()
process.terminationHandler = { _ in
termination.resolve(timedOut: false)
}

var processGroup: pid_t?
do {
try process.run()
Expand All @@ -50,7 +60,7 @@ struct CodexLoginRunner {
return Result(outcome: .launchFailed(error.localizedDescription), output: "")
}

let timedOut = await self.wait(for: process, timeout: timeout)
let timedOut = await self.wait(timeout: timeout, termination: termination)
if timedOut {
self.terminate(process, processGroup: processGroup)
}
Expand All @@ -68,23 +78,60 @@ struct CodexLoginRunner {
}.value
}

private static func wait(for process: Process, timeout: TimeInterval) async -> Bool {
await withTaskGroup(of: Bool.self) { group -> Bool in
group.addTask {
process.waitUntilExit()
return false
private final class ProcessTermination: @unchecked Sendable {
private let lock = NSLock()
private var timedOut: Bool?
private var continuation: CheckedContinuation<Bool, Never>?

func resolve(timedOut: Bool) {
let continuation: CheckedContinuation<Bool, Never>?
self.lock.lock()
guard self.timedOut == nil else {
self.lock.unlock()
return
}
group.addTask {
let nanos = UInt64(max(0, timeout) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
return true
self.timedOut = timedOut
continuation = self.continuation
self.continuation = nil
self.lock.unlock()
continuation?.resume(returning: timedOut)
}

func wait() async -> Bool {
await withCheckedContinuation { continuation in
let timedOut: Bool?
self.lock.lock()
timedOut = self.timedOut
if timedOut == nil {
self.continuation = continuation
}
self.lock.unlock()

if let timedOut {
continuation.resume(returning: timedOut)
}
}
let result = await group.next() ?? false
group.cancelAll()
return result
}
}

private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool {
let timeoutTask = Task.detached(priority: .userInitiated) {
try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout))
if Task.isCancelled == false {
termination.resolve(timedOut: true)
}
}
let timedOut = await termination.wait()
timeoutTask.cancel()
return timedOut
}

private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 {
guard timeout.isFinite else { return UInt64.max }
let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000))
return UInt64(seconds * 1_000_000_000)
}

private static func terminate(_ process: Process, processGroup: pid_t?) {
if let pgid = processGroup {
kill(-pgid, SIGTERM)
Expand Down
Loading
Loading