Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions lib/codex-manager/settings-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ type ThemeConfigAction =

type BackendToggleSettingKey =
| "liveAccountSync"
| "codexCliSessionSupervisor"
| "sessionAffinity"
| "proactiveRefreshGuardian"
| "retryAllAccountsRateLimited"
Expand Down Expand Up @@ -272,6 +273,7 @@ type SettingsHubAction =
| { type: "back" };

type ExperimentalSettingsAction =
| { type: "toggle-session-supervisor" }
| { type: "sync" }
| { type: "backup" }
| { type: "toggle-refresh-guardian" }
Expand Down Expand Up @@ -300,9 +302,10 @@ function getExperimentalSelectOptions(
function mapExperimentalMenuHotkey(
raw: string,
): ExperimentalSettingsAction | undefined {
if (raw === "1") return { type: "sync" };
if (raw === "2") return { type: "backup" };
if (raw === "3") return { type: "toggle-refresh-guardian" };
if (raw === "1") return { type: "toggle-session-supervisor" };
if (raw === "2") return { type: "sync" };
if (raw === "3") return { type: "backup" };
if (raw === "4") return { type: "toggle-refresh-guardian" };
if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" };
if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" };
const lower = raw.toLowerCase();
Expand All @@ -323,6 +326,11 @@ const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [
label: "Enable Live Sync",
description: "Keep accounts synced when files change in another window.",
},
{
key: "codexCliSessionSupervisor",
label: "Enable Session Resume Supervisor",
description: "Wrap interactive Codex sessions so they can relaunch with resume after rotation.",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
{
key: "sessionAffinity",
label: "Enable Session Affinity",
Expand Down Expand Up @@ -2568,6 +2576,11 @@ async function promptExperimentalSettings(
while (true) {
const action = await select<ExperimentalSettingsAction>(
[
{
label: `${formatDashboardSettingState(draft.codexCliSessionSupervisor ?? BACKEND_DEFAULTS.codexCliSessionSupervisor ?? false)} ${UI_COPY.settings.experimentalSessionSupervisor}`,
value: { type: "toggle-session-supervisor" },
color: "yellow",
},
{
label: UI_COPY.settings.experimentalSync,
value: { type: "sync" },
Expand Down Expand Up @@ -2619,6 +2632,17 @@ async function promptExperimentalSettings(
);
if (!action || action.type === "back") return null;
if (action.type === "save") return draft;
if (action.type === "toggle-session-supervisor") {
draft = {
...draft,
codexCliSessionSupervisor: !(
draft.codexCliSessionSupervisor ??
BACKEND_DEFAULTS.codexCliSessionSupervisor ??
false
),
};
continue;
}
if (action.type === "toggle-refresh-guardian") {
draft = {
...draft,
Expand Down
20 changes: 20 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
liveAccountSync: true,
liveAccountSyncDebounceMs: 250,
liveAccountSyncPollMs: 2_000,
codexCliSessionSupervisor: false,
sessionAffinity: true,
sessionAffinityTtlMs: 20 * 60_000,
sessionAffinityMaxEntries: 512,
Expand Down Expand Up @@ -857,6 +858,25 @@ export function getLiveAccountSyncPollMs(pluginConfig: PluginConfig): number {
);
}

/**
* Determines whether the CLI session supervisor wrapper is enabled.
*
* This accessor is synchronous, side-effect free, and safe for concurrent reads.
* It performs no filesystem I/O and does not expose token material.
*
* @param pluginConfig - The plugin configuration object used as the non-environment fallback
* @returns `true` when the session supervisor should wrap interactive Codex sessions
*/
export function getCodexCliSessionSupervisor(
pluginConfig: PluginConfig,
): boolean {
return resolveBooleanSetting(
"CODEX_AUTH_CLI_SESSION_SUPERVISOR",
pluginConfig.codexCliSessionSupervisor,
false,
);
}

/**
* Indicates whether session affinity is enabled.
*
Expand Down
24 changes: 24 additions & 0 deletions lib/quota-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ export interface ProbeCodexQuotaOptions {
model?: string;
fallbackModels?: readonly string[];
timeoutMs?: number;
signal?: AbortSignal;
}

function createAbortError(message: string): Error {
const error = new Error(message);
error.name = "AbortError";
return error;
}

/**
Expand All @@ -331,6 +338,9 @@ export async function fetchCodexQuotaSnapshot(
let lastError: Error | null = null;

for (const model of models) {
if (options.signal?.aborted) {
throw createAbortError("Quota probe aborted");
}
try {
const instructions = await getCodexInstructions(model);
const probeBody: RequestBody = {
Expand All @@ -356,6 +366,12 @@ export async function fetchCodexQuotaSnapshot(
headers.set("content-type", "application/json");

const controller = new AbortController();
const onAbort = () => controller.abort(options.signal?.reason);
if (options.signal?.aborted) {
controller.abort(options.signal.reason);
} else {
options.signal?.addEventListener("abort", onAbort, { once: true });
}
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
Expand All @@ -367,6 +383,7 @@ export async function fetchCodexQuotaSnapshot(
});
} finally {
clearTimeout(timeout);
options.signal?.removeEventListener("abort", onAbort);
}

const snapshotBase = parseQuotaSnapshotBase(response.headers, response.status);
Expand Down Expand Up @@ -406,9 +423,16 @@ export async function fetchCodexQuotaSnapshot(
}
lastError = new Error("Codex response did not include quota headers");
} catch (error) {
if (options.signal?.aborted) {
throw error instanceof Error ? error : createAbortError("Quota probe aborted");
}
lastError = error instanceof Error ? error : new Error(String(error));
}
}

if (options.signal?.aborted) {
throw createAbortError("Quota probe aborted");
}

throw lastError ?? new Error("Failed to fetch quotas");
}
1 change: 1 addition & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const PluginConfigSchema = z.object({
liveAccountSync: z.boolean().optional(),
liveAccountSyncDebounceMs: z.number().min(50).optional(),
liveAccountSyncPollMs: z.number().min(500).optional(),
codexCliSessionSupervisor: z.boolean().optional(),
sessionAffinity: z.boolean().optional(),
sessionAffinityTtlMs: z.number().min(1_000).optional(),
sessionAffinityMaxEntries: z.number().min(8).optional(),
Expand Down
98 changes: 71 additions & 27 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,20 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] {
return candidates;
}

function normalizeStorageComparisonPath(path: string): string {
const resolved = resolvePath(path);
if (process.platform !== "win32") {
return resolved;
}
return resolved.replaceAll("\\", "/").toLowerCase();
}

function areEquivalentStoragePaths(left: string, right: string): boolean {
return (
normalizeStorageComparisonPath(left) === normalizeStorageComparisonPath(right)
);
}

async function getAccountsBackupRecoveryCandidatesWithDiscovery(
path: string,
): Promise<string[]> {
Expand Down Expand Up @@ -813,8 +827,13 @@ function latestValidSnapshot(
snapshots: BackupSnapshotMetadata[],
): BackupSnapshotMetadata | undefined {
return snapshots
.filter((snapshot) => snapshot.valid)
.sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0];
.map((snapshot, index) => ({ snapshot, index }))
.filter(({ snapshot }) => snapshot.valid)
.sort(
(left, right) =>
(right.snapshot.mtimeMs ?? 0) - (left.snapshot.mtimeMs ?? 0) ||
left.index - right.index,
)[0]?.snapshot;
}

function buildMetadataSection(
Expand Down Expand Up @@ -1761,8 +1780,9 @@ async function loadAccountsFromJournal(

async function loadAccountsInternal(
persistMigration: ((storage: AccountStorageV3) => Promise<void>) | null,
storagePath = getStoragePath(),
): Promise<AccountStorageV3 | null> {
const path = getStoragePath();
const path = storagePath;
const resetMarkerPath = getIntentionalResetMarkerPath(path);
await cleanupStaleRotatingBackupArtifacts(path);
const migratedLegacyStorage = persistMigration
Expand Down Expand Up @@ -1926,8 +1946,11 @@ async function loadAccountsInternal(
}
}

async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
const path = getStoragePath();
async function saveAccountsUnlocked(
storage: AccountStorageV3,
storagePath = getStoragePath(),
): Promise<void> {
const path = storagePath;
const resetMarkerPath = getIntentionalResetMarkerPath(path);
const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
const tempPath = `${path}.${uniqueSuffix}.tmp`;
Expand Down Expand Up @@ -2078,18 +2101,25 @@ export async function withAccountStorageTransaction<T>(
return withStorageLock(async () => {
const storagePath = getStoragePath();
const state = {
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
storagePath,
snapshot: await loadAccountsInternal(
(storage) => saveAccountsUnlocked(storage, storagePath),
storagePath,
),
active: true,
storagePath,
};
const current = state.snapshot;
const persist = async (storage: AccountStorageV3): Promise<void> => {
await saveAccountsUnlocked(storage);
await saveAccountsUnlocked(storage, storagePath);
state.snapshot = storage;
};
return transactionSnapshotContext.run(state, () =>
handler(current, persist),
);
return transactionSnapshotContext.run(state, async () => {
try {
return await handler(current, persist);
} finally {
state.active = false;
}
});
});
}

Expand All @@ -2105,9 +2135,12 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
return withStorageLock(async () => {
const storagePath = getStoragePath();
const state = {
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
storagePath,
snapshot: await loadAccountsInternal(
(storage) => saveAccountsUnlocked(storage, storagePath),
storagePath,
),
active: true,
storagePath,
};
const current = state.snapshot;
const persist = async (
Expand All @@ -2116,13 +2149,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
): Promise<void> => {
const previousAccounts = cloneAccountStorageForPersistence(state.snapshot);
const nextAccounts = cloneAccountStorageForPersistence(accountStorage);
await saveAccountsUnlocked(nextAccounts);
await saveAccountsUnlocked(nextAccounts, storagePath);
try {
await saveFlaggedAccountsUnlocked(flaggedStorage);
state.snapshot = nextAccounts;
} catch (error) {
try {
await saveAccountsUnlocked(previousAccounts);
await saveAccountsUnlocked(previousAccounts, storagePath);
state.snapshot = previousAccounts;
} catch (rollbackError) {
const combinedError = new AggregateError(
Expand All @@ -2141,9 +2174,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
throw error;
}
};
return transactionSnapshotContext.run(state, () =>
handler(current, persist),
);
return transactionSnapshotContext.run(state, async () => {
try {
return await handler(current, persist);
} finally {
state.active = false;
}
});
});
}

Expand Down Expand Up @@ -2477,22 +2514,29 @@ export async function exportAccounts(
beforeCommit?: (resolvedPath: string) => Promise<void> | void,
): Promise<void> {
const resolvedPath = resolvePath(filePath);
const currentStoragePath = getStoragePath();
const activeStoragePath = getStoragePath();

if (!force && existsSync(resolvedPath)) {
throw new Error(`File already exists: ${resolvedPath}`);
}

const transactionState = transactionSnapshotContext.getStore();
const storage =
if (
transactionState?.active &&
transactionState.storagePath === currentStoragePath
? transactionState.snapshot
: transactionState?.active
? await loadAccountsInternal(saveAccountsUnlocked)
: await withAccountStorageTransaction((current) =>
Promise.resolve(current),
);
!areEquivalentStoragePaths(transactionState.storagePath, activeStoragePath)
) {
// A fresh load here can silently export from the wrong storage file while a
// different transaction still owns the current snapshot.
throw new Error(
`Export blocked by storage path mismatch: transaction path is ` +
`${transactionState.storagePath}, active path is ${activeStoragePath}`,
);
}
const storage = transactionState?.active
? transactionState.snapshot
: await withAccountStorageTransaction((current) =>
Promise.resolve(current),
);
if (!storage || storage.accounts.length === 0) {
throw new Error("No accounts to export");
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down
3 changes: 2 additions & 1 deletion lib/ui/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ export const UI_COPY = {
experimentalTitle: "Experimental",
experimentalSubtitle: "Preview sync and backup actions before they become stable",
experimentalHelpMenu:
"Enter Select | 1 Sync | 2 Backup | 3 Guard | [ - Down | ] + Up | S Save | Q Back",
"Enter Select | 1 Supervisor | 2 Sync | 3 Backup | 4 Guard | [ - Down | ] + Up | S Save | Q Back",
experimentalHelpPreview: "Enter Select | A Apply | Q Back",
experimentalHelpStatus: "Enter Select | Q Back",
experimentalSessionSupervisor: "Enable Session Resume Supervisor",
experimentalSync: "Sync Accounts to oc-chatgpt-multi-auth",
experimentalApplySync: "Apply Sync",
experimentalBackup: "Save Pool Backup",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"test:model-matrix": "node scripts/test-model-matrix.js",
"test:model-matrix:smoke": "node scripts/test-model-matrix.js --smoke",
"test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json",
"test:session-supervisor:smoke": "vitest run test/codex-supervisor.test.ts test/codex-bin-wrapper.test.ts test/plugin-config.test.ts test/quota-probe.test.ts test/settings-hub-utils.test.ts",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive",
"clean:repo:check": "node scripts/repo-hygiene.js check",
"bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core",
Expand Down
Loading