Skip to content

Commit 8b878c5

Browse files
committed
Add an experimental Codex session supervisor
1 parent b918aac commit 8b878c5

16 files changed

+5579
-126
lines changed

lib/codex-manager/settings-hub.ts

Lines changed: 98 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { PluginConfig } from "../types.js";
2828
import { ANSI } from "../ui/ansi.js";
2929
import { UI_COPY } from "../ui/copy.js";
3030
import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js";
31-
import { type MenuItem, select } from "../ui/select.js";
31+
import { type MenuItem, select, type SelectOptions } from "../ui/select.js";
3232
import { getUnifiedSettingsPath } from "../unified-settings.js";
3333
import { sleep } from "../utils.js";
3434

@@ -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" }
@@ -281,12 +283,54 @@ type ExperimentalSettingsAction =
281283
| { type: "save" }
282284
| { type: "back" };
283285

286+
function getExperimentalSelectOptions(
287+
ui: ReturnType<typeof getUiRuntimeOptions>,
288+
help: string,
289+
onInput?: SelectOptions<ExperimentalSettingsAction>["onInput"],
290+
): SelectOptions<ExperimentalSettingsAction> {
291+
return {
292+
message: UI_COPY.settings.experimentalTitle,
293+
subtitle: UI_COPY.settings.experimentalSubtitle,
294+
help,
295+
clearScreen: true,
296+
theme: ui.theme,
297+
selectedEmphasis: "minimal",
298+
onInput,
299+
};
300+
}
301+
302+
function mapExperimentalMenuHotkey(
303+
raw: string,
304+
): ExperimentalSettingsAction | undefined {
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" };
309+
if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" };
310+
if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" };
311+
const lower = raw.toLowerCase();
312+
if (lower === "q") return { type: "back" };
313+
if (lower === "s") return { type: "save" };
314+
return undefined;
315+
}
316+
317+
function mapExperimentalStatusHotkey(
318+
raw: string,
319+
): ExperimentalSettingsAction | undefined {
320+
return raw.toLowerCase() === "q" ? { type: "back" } : undefined;
321+
}
322+
284323
const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [
285324
{
286325
key: "liveAccountSync",
287326
label: "Enable Live Sync",
288327
description: "Keep accounts synced when files change in another window.",
289328
},
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+
},
290334
{
291335
key: "sessionAffinity",
292336
label: "Enable Session Affinity",
@@ -1279,6 +1323,8 @@ const __testOnly = {
12791323
cloneDashboardSettings,
12801324
withQueuedRetry: withQueuedRetryForTests,
12811325
loadExperimentalSyncTarget,
1326+
mapExperimentalMenuHotkey,
1327+
mapExperimentalStatusHotkey,
12821328
promptExperimentalSettings,
12831329
persistDashboardSettingsSelection: persistDashboardSettingsSelectionForTests,
12841330
persistBackendConfigSelection: persistBackendConfigSelectionForTests,
@@ -2530,6 +2576,11 @@ async function promptExperimentalSettings(
25302576
while (true) {
25312577
const action = await select<ExperimentalSettingsAction>(
25322578
[
2579+
{
2580+
label: `${formatDashboardSettingState(draft.codexCliSessionSupervisor ?? BACKEND_DEFAULTS.codexCliSessionSupervisor ?? false)} ${UI_COPY.settings.experimentalSessionSupervisor}`,
2581+
value: { type: "toggle-session-supervisor" },
2582+
color: "yellow",
2583+
},
25332584
{
25342585
label: UI_COPY.settings.experimentalSync,
25352586
value: { type: "sync" },
@@ -2573,19 +2624,25 @@ async function promptExperimentalSettings(
25732624
color: "red",
25742625
},
25752626
],
2576-
{
2577-
message: UI_COPY.settings.experimentalTitle,
2578-
subtitle: UI_COPY.settings.experimentalSubtitle,
2579-
help: UI_COPY.settings.experimentalHelpMenu,
2580-
clearScreen: true,
2581-
theme: ui.theme,
2582-
selectedEmphasis: "minimal",
2583-
onInput: (raw) =>
2584-
raw.toLowerCase() === "q" ? { type: "back" } : undefined,
2585-
},
2627+
getExperimentalSelectOptions(
2628+
ui,
2629+
UI_COPY.settings.experimentalHelpMenu,
2630+
mapExperimentalMenuHotkey,
2631+
),
25862632
);
25872633
if (!action || action.type === "back") return null;
25882634
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+
}
25892646
if (action.type === "toggle-refresh-guardian") {
25902647
draft = {
25912648
...draft,
@@ -2647,14 +2704,11 @@ async function promptExperimentalSettings(
26472704
color: "red",
26482705
},
26492706
],
2650-
{
2651-
message: UI_COPY.settings.experimentalTitle,
2652-
subtitle: UI_COPY.settings.experimentalSubtitle,
2653-
help: UI_COPY.settings.experimentalHelpStatus,
2654-
clearScreen: true,
2655-
theme: ui.theme,
2656-
selectedEmphasis: "minimal",
2657-
},
2707+
getExperimentalSelectOptions(
2708+
ui,
2709+
UI_COPY.settings.experimentalHelpStatus,
2710+
mapExperimentalStatusHotkey,
2711+
),
26582712
);
26592713
} catch (error) {
26602714
const message =
@@ -2674,14 +2728,11 @@ async function promptExperimentalSettings(
26742728
color: "red",
26752729
},
26762730
],
2677-
{
2678-
message: UI_COPY.settings.experimentalTitle,
2679-
subtitle: UI_COPY.settings.experimentalSubtitle,
2680-
help: UI_COPY.settings.experimentalHelpStatus,
2681-
clearScreen: true,
2682-
theme: ui.theme,
2683-
selectedEmphasis: "minimal",
2684-
},
2731+
getExperimentalSelectOptions(
2732+
ui,
2733+
UI_COPY.settings.experimentalHelpStatus,
2734+
mapExperimentalStatusHotkey,
2735+
),
26852736
);
26862737
}
26872738
} finally {
@@ -2708,14 +2759,11 @@ async function promptExperimentalSettings(
27082759
color: "red",
27092760
},
27102761
],
2711-
{
2712-
message: UI_COPY.settings.experimentalTitle,
2713-
subtitle: UI_COPY.settings.experimentalSubtitle,
2714-
help: UI_COPY.settings.experimentalHelpStatus,
2715-
clearScreen: true,
2716-
theme: ui.theme,
2717-
selectedEmphasis: "minimal",
2718-
},
2762+
getExperimentalSelectOptions(
2763+
ui,
2764+
UI_COPY.settings.experimentalHelpStatus,
2765+
mapExperimentalStatusHotkey,
2766+
),
27192767
);
27202768
continue;
27212769
}
@@ -2747,14 +2795,11 @@ async function promptExperimentalSettings(
27472795
color: "red",
27482796
},
27492797
],
2750-
{
2751-
message: UI_COPY.settings.experimentalTitle,
2752-
subtitle: UI_COPY.settings.experimentalSubtitle,
2753-
help: UI_COPY.settings.experimentalHelpStatus,
2754-
clearScreen: true,
2755-
theme: ui.theme,
2756-
selectedEmphasis: "minimal",
2757-
},
2798+
getExperimentalSelectOptions(
2799+
ui,
2800+
UI_COPY.settings.experimentalHelpStatus,
2801+
mapExperimentalStatusHotkey,
2802+
),
27582803
);
27592804
continue;
27602805
}
@@ -2793,20 +2838,16 @@ async function promptExperimentalSettings(
27932838
color: "red",
27942839
},
27952840
],
2796-
{
2797-
message: UI_COPY.settings.experimentalTitle,
2798-
subtitle: UI_COPY.settings.experimentalSubtitle,
2799-
help: UI_COPY.settings.experimentalHelpStatus,
2800-
clearScreen: true,
2801-
theme: ui.theme,
2802-
selectedEmphasis: "minimal",
2803-
onInput: (raw) => {
2841+
getExperimentalSelectOptions(
2842+
ui,
2843+
UI_COPY.settings.experimentalHelpPreview,
2844+
(raw) => {
28042845
const lower = raw.toLowerCase();
28052846
if (lower === "q") return { type: "back" };
28062847
if (lower === "a") return { type: "apply" };
28072848
return undefined;
28082849
},
2809-
},
2850+
),
28102851
);
28112852
if (!review || review.type === "back") continue;
28122853

@@ -2837,14 +2878,11 @@ async function promptExperimentalSettings(
28372878
},
28382879
{ label: UI_COPY.settings.back, value: { type: "back" }, color: "red" },
28392880
],
2840-
{
2841-
message: UI_COPY.settings.experimentalTitle,
2842-
subtitle: UI_COPY.settings.experimentalSubtitle,
2843-
help: UI_COPY.settings.experimentalHelpStatus,
2844-
clearScreen: true,
2845-
theme: ui.theme,
2846-
selectedEmphasis: "minimal",
2847-
},
2881+
getExperimentalSelectOptions(
2882+
ui,
2883+
UI_COPY.settings.experimentalHelpStatus,
2884+
mapExperimentalStatusHotkey,
2885+
),
28482886
);
28492887
}
28502888
}

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: 8 additions & 1 deletion
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(),
@@ -82,7 +83,13 @@ export type CooldownReasonFromSchema = z.infer<typeof CooldownReasonSchema>;
8283
/**
8384
* Last switch reason for account rotation tracking.
8485
*/
85-
export const SwitchReasonSchema = z.enum(["rate-limit", "initial", "rotation", "best"]);
86+
export const SwitchReasonSchema = z.enum([
87+
"rate-limit",
88+
"initial",
89+
"rotation",
90+
"best",
91+
"restore",
92+
]);
8693

8794
export type SwitchReasonFromSchema = z.infer<typeof SwitchReasonSchema>;
8895

0 commit comments

Comments
 (0)