Skip to content

Commit 98c5185

Browse files
committed
Add an experimental Codex session supervisor
1 parent 1be5e95 commit 98c5185

16 files changed

Lines changed: 5194 additions & 41 deletions

lib/codex-manager/settings-hub.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ type ThemeConfigAction =
186186

187187
type BackendToggleSettingKey =
188188
| "liveAccountSync"
189+
| "codexCliSessionSupervisor"
189190
| "sessionAffinity"
190191
| "proactiveRefreshGuardian"
191192
| "retryAllAccountsRateLimited"
@@ -272,6 +273,7 @@ type SettingsHubAction =
272273
| { type: "back" };
273274

274275
type ExperimentalSettingsAction =
276+
| { type: "toggle-session-supervisor" }
275277
| { type: "sync" }
276278
| { type: "backup" }
277279
| { type: "toggle-refresh-guardian" }
@@ -300,9 +302,10 @@ function getExperimentalSelectOptions(
300302
function mapExperimentalMenuHotkey(
301303
raw: string,
302304
): ExperimentalSettingsAction | undefined {
303-
if (raw === "1") return { type: "sync" };
304-
if (raw === "2") return { type: "backup" };
305-
if (raw === "3") return { type: "toggle-refresh-guardian" };
305+
if (raw === "1") return { type: "toggle-session-supervisor" };
306+
if (raw === "2") return { type: "sync" };
307+
if (raw === "3") return { type: "backup" };
308+
if (raw === "4") return { type: "toggle-refresh-guardian" };
306309
if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" };
307310
if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" };
308311
const lower = raw.toLowerCase();
@@ -323,6 +326,11 @@ const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [
323326
label: "Enable Live Sync",
324327
description: "Keep accounts synced when files change in another window.",
325328
},
329+
{
330+
key: "codexCliSessionSupervisor",
331+
label: "Enable Session Resume Supervisor",
332+
description: "Wrap interactive Codex sessions so they can relaunch with resume after rotation.",
333+
},
326334
{
327335
key: "sessionAffinity",
328336
label: "Enable Session Affinity",
@@ -2568,6 +2576,11 @@ async function promptExperimentalSettings(
25682576
while (true) {
25692577
const action = await select<ExperimentalSettingsAction>(
25702578
[
2579+
{
2580+
label: `${formatDashboardSettingState(draft.codexCliSessionSupervisor ?? BACKEND_DEFAULTS.codexCliSessionSupervisor ?? false)} ${UI_COPY.settings.experimentalSessionSupervisor}`,
2581+
value: { type: "toggle-session-supervisor" },
2582+
color: "yellow",
2583+
},
25712584
{
25722585
label: UI_COPY.settings.experimentalSync,
25732586
value: { type: "sync" },
@@ -2619,6 +2632,17 @@ async function promptExperimentalSettings(
26192632
);
26202633
if (!action || action.type === "back") return null;
26212634
if (action.type === "save") return draft;
2635+
if (action.type === "toggle-session-supervisor") {
2636+
draft = {
2637+
...draft,
2638+
codexCliSessionSupervisor: !(
2639+
draft.codexCliSessionSupervisor ??
2640+
BACKEND_DEFAULTS.codexCliSessionSupervisor ??
2641+
false
2642+
),
2643+
};
2644+
continue;
2645+
}
26222646
if (action.type === "toggle-refresh-guardian") {
26232647
draft = {
26242648
...draft,

lib/config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
145145
liveAccountSync: true,
146146
liveAccountSyncDebounceMs: 250,
147147
liveAccountSyncPollMs: 2_000,
148+
codexCliSessionSupervisor: false,
148149
sessionAffinity: true,
149150
sessionAffinityTtlMs: 20 * 60_000,
150151
sessionAffinityMaxEntries: 512,
@@ -857,6 +858,25 @@ export function getLiveAccountSyncPollMs(pluginConfig: PluginConfig): number {
857858
);
858859
}
859860

861+
/**
862+
* Determines whether the CLI session supervisor wrapper is enabled.
863+
*
864+
* This accessor is synchronous, side-effect free, and safe for concurrent reads.
865+
* It performs no filesystem I/O and does not expose token material.
866+
*
867+
* @param pluginConfig - The plugin configuration object used as the non-environment fallback
868+
* @returns `true` when the session supervisor should wrap interactive Codex sessions
869+
*/
870+
export function getCodexCliSessionSupervisor(
871+
pluginConfig: PluginConfig,
872+
): boolean {
873+
return resolveBooleanSetting(
874+
"CODEX_AUTH_CLI_SESSION_SUPERVISOR",
875+
pluginConfig.codexCliSessionSupervisor,
876+
false,
877+
);
878+
}
879+
860880
/**
861881
* Indicates whether session affinity is enabled.
862882
*

lib/quota-probe.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ export interface ProbeCodexQuotaOptions {
305305
model?: string;
306306
fallbackModels?: readonly string[];
307307
timeoutMs?: number;
308+
signal?: AbortSignal;
309+
}
310+
311+
function createAbortError(message: string): Error {
312+
const error = new Error(message);
313+
error.name = "AbortError";
314+
return error;
308315
}
309316

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

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

358368
const controller = new AbortController();
369+
const onAbort = () => controller.abort(options.signal?.reason);
370+
if (options.signal?.aborted) {
371+
controller.abort(options.signal.reason);
372+
} else {
373+
options.signal?.addEventListener("abort", onAbort, { once: true });
374+
}
359375
const timeout = setTimeout(() => controller.abort(), timeoutMs);
360376
let response: Response;
361377
try {
@@ -367,6 +383,7 @@ export async function fetchCodexQuotaSnapshot(
367383
});
368384
} finally {
369385
clearTimeout(timeout);
386+
options.signal?.removeEventListener("abort", onAbort);
370387
}
371388

372389
const snapshotBase = parseQuotaSnapshotBase(response.headers, response.status);
@@ -406,9 +423,16 @@ export async function fetchCodexQuotaSnapshot(
406423
}
407424
lastError = new Error("Codex response did not include quota headers");
408425
} catch (error) {
426+
if (options.signal?.aborted) {
427+
throw error instanceof Error ? error : createAbortError("Quota probe aborted");
428+
}
409429
lastError = error instanceof Error ? error : new Error(String(error));
410430
}
411431
}
412432

433+
if (options.signal?.aborted) {
434+
throw createAbortError("Quota probe aborted");
435+
}
436+
413437
throw lastError ?? new Error("Failed to fetch quotas");
414438
}

lib/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const PluginConfigSchema = z.object({
4444
liveAccountSync: z.boolean().optional(),
4545
liveAccountSyncDebounceMs: z.number().min(50).optional(),
4646
liveAccountSyncPollMs: z.number().min(500).optional(),
47+
codexCliSessionSupervisor: z.boolean().optional(),
4748
sessionAffinity: z.boolean().optional(),
4849
sessionAffinityTtlMs: z.number().min(1_000).optional(),
4950
sessionAffinityMaxEntries: z.number().min(8).optional(),

lib/storage.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,20 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] {
381381
return candidates;
382382
}
383383

384+
function normalizeStorageComparisonPath(path: string): string {
385+
const resolved = resolvePath(path);
386+
if (process.platform !== "win32") {
387+
return resolved;
388+
}
389+
return resolved.replaceAll("\\", "/").toLowerCase();
390+
}
391+
392+
function areEquivalentStoragePaths(left: string, right: string): boolean {
393+
return (
394+
normalizeStorageComparisonPath(left) === normalizeStorageComparisonPath(right)
395+
);
396+
}
397+
384398
async function getAccountsBackupRecoveryCandidatesWithDiscovery(
385399
path: string,
386400
): Promise<string[]> {
@@ -813,8 +827,13 @@ function latestValidSnapshot(
813827
snapshots: BackupSnapshotMetadata[],
814828
): BackupSnapshotMetadata | undefined {
815829
return snapshots
816-
.filter((snapshot) => snapshot.valid)
817-
.sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0];
830+
.map((snapshot, index) => ({ snapshot, index }))
831+
.filter(({ snapshot }) => snapshot.valid)
832+
.sort(
833+
(left, right) =>
834+
(right.snapshot.mtimeMs ?? 0) - (left.snapshot.mtimeMs ?? 0) ||
835+
left.index - right.index,
836+
)[0]?.snapshot;
818837
}
819838

820839
function buildMetadataSection(
@@ -1761,8 +1780,9 @@ async function loadAccountsFromJournal(
17611780

17621781
async function loadAccountsInternal(
17631782
persistMigration: ((storage: AccountStorageV3) => Promise<void>) | null,
1783+
storagePath = getStoragePath(),
17641784
): Promise<AccountStorageV3 | null> {
1765-
const path = getStoragePath();
1785+
const path = storagePath;
17661786
const resetMarkerPath = getIntentionalResetMarkerPath(path);
17671787
await cleanupStaleRotatingBackupArtifacts(path);
17681788
const migratedLegacyStorage = persistMigration
@@ -1926,8 +1946,11 @@ async function loadAccountsInternal(
19261946
}
19271947
}
19281948

1929-
async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
1930-
const path = getStoragePath();
1949+
async function saveAccountsUnlocked(
1950+
storage: AccountStorageV3,
1951+
storagePath = getStoragePath(),
1952+
): Promise<void> {
1953+
const path = storagePath;
19311954
const resetMarkerPath = getIntentionalResetMarkerPath(path);
19321955
const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
19331956
const tempPath = `${path}.${uniqueSuffix}.tmp`;
@@ -2078,18 +2101,25 @@ export async function withAccountStorageTransaction<T>(
20782101
return withStorageLock(async () => {
20792102
const storagePath = getStoragePath();
20802103
const state = {
2081-
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
2082-
storagePath,
2104+
snapshot: await loadAccountsInternal(
2105+
(storage) => saveAccountsUnlocked(storage, storagePath),
2106+
storagePath,
2107+
),
20832108
active: true,
2109+
storagePath,
20842110
};
20852111
const current = state.snapshot;
20862112
const persist = async (storage: AccountStorageV3): Promise<void> => {
2087-
await saveAccountsUnlocked(storage);
2113+
await saveAccountsUnlocked(storage, storagePath);
20882114
state.snapshot = storage;
20892115
};
2090-
return transactionSnapshotContext.run(state, () =>
2091-
handler(current, persist),
2092-
);
2116+
return transactionSnapshotContext.run(state, async () => {
2117+
try {
2118+
return await handler(current, persist);
2119+
} finally {
2120+
state.active = false;
2121+
}
2122+
});
20932123
});
20942124
}
20952125

@@ -2105,9 +2135,12 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
21052135
return withStorageLock(async () => {
21062136
const storagePath = getStoragePath();
21072137
const state = {
2108-
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
2109-
storagePath,
2138+
snapshot: await loadAccountsInternal(
2139+
(storage) => saveAccountsUnlocked(storage, storagePath),
2140+
storagePath,
2141+
),
21102142
active: true,
2143+
storagePath,
21112144
};
21122145
const current = state.snapshot;
21132146
const persist = async (
@@ -2116,13 +2149,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
21162149
): Promise<void> => {
21172150
const previousAccounts = cloneAccountStorageForPersistence(state.snapshot);
21182151
const nextAccounts = cloneAccountStorageForPersistence(accountStorage);
2119-
await saveAccountsUnlocked(nextAccounts);
2152+
await saveAccountsUnlocked(nextAccounts, storagePath);
21202153
try {
21212154
await saveFlaggedAccountsUnlocked(flaggedStorage);
21222155
state.snapshot = nextAccounts;
21232156
} catch (error) {
21242157
try {
2125-
await saveAccountsUnlocked(previousAccounts);
2158+
await saveAccountsUnlocked(previousAccounts, storagePath);
21262159
state.snapshot = previousAccounts;
21272160
} catch (rollbackError) {
21282161
const combinedError = new AggregateError(
@@ -2141,9 +2174,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
21412174
throw error;
21422175
}
21432176
};
2144-
return transactionSnapshotContext.run(state, () =>
2145-
handler(current, persist),
2146-
);
2177+
return transactionSnapshotContext.run(state, async () => {
2178+
try {
2179+
return await handler(current, persist);
2180+
} finally {
2181+
state.active = false;
2182+
}
2183+
});
21472184
});
21482185
}
21492186

@@ -2477,22 +2514,27 @@ export async function exportAccounts(
24772514
beforeCommit?: (resolvedPath: string) => Promise<void> | void,
24782515
): Promise<void> {
24792516
const resolvedPath = resolvePath(filePath);
2480-
const currentStoragePath = getStoragePath();
2517+
const activeStoragePath = getStoragePath();
24812518

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

24862523
const transactionState = transactionSnapshotContext.getStore();
2487-
const storage =
2524+
if (
24882525
transactionState?.active &&
2489-
transactionState.storagePath === currentStoragePath
2490-
? transactionState.snapshot
2491-
: transactionState?.active
2492-
? await loadAccountsInternal(saveAccountsUnlocked)
2493-
: await withAccountStorageTransaction((current) =>
2494-
Promise.resolve(current),
2495-
);
2526+
!areEquivalentStoragePaths(transactionState.storagePath, activeStoragePath)
2527+
) {
2528+
throw new Error(
2529+
`Export blocked by storage path mismatch: transaction path is ` +
2530+
`${transactionState.storagePath}, active path is ${activeStoragePath}`,
2531+
);
2532+
}
2533+
const storage = transactionState?.active
2534+
? transactionState.snapshot
2535+
: await withAccountStorageTransaction((current) =>
2536+
Promise.resolve(current),
2537+
);
24962538
if (!storage || storage.accounts.length === 0) {
24972539
throw new Error("No accounts to export");
24982540
}

lib/ui/copy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ export const UI_COPY = {
9595
experimentalTitle: "Experimental",
9696
experimentalSubtitle: "Preview sync and backup actions before they become stable",
9797
experimentalHelpMenu:
98-
"Enter Select | 1 Sync | 2 Backup | 3 Guard | [ - Down | ] + Up | S Save | Q Back",
98+
"Enter Select | 1 Supervisor | 2 Sync | 3 Backup | 4 Guard | [ - Down | ] + Up | S Save | Q Back",
9999
experimentalHelpPreview: "Enter Select | A Apply | Q Back",
100100
experimentalHelpStatus: "Enter Select | Q Back",
101+
experimentalSessionSupervisor: "Enable Session Resume Supervisor",
101102
experimentalSync: "Sync Accounts to oc-chatgpt-multi-auth",
102103
experimentalApplySync: "Apply Sync",
103104
experimentalBackup: "Save Pool Backup",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"test:model-matrix": "node scripts/test-model-matrix.js",
5252
"test:model-matrix:smoke": "node scripts/test-model-matrix.js --smoke",
5353
"test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json",
54+
"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",
5455
"clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive",
5556
"clean:repo:check": "node scripts/repo-hygiene.js check",
5657
"bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core",

0 commit comments

Comments
 (0)